Merge pull request #374 from EdouardVanbelle/style/type

This commit is contained in:
Dionisio Pozo
2026-05-19 18:21:20 +02:00
committed by GitHub
99 changed files with 2476 additions and 620 deletions
+7 -3
View File
@@ -42,7 +42,7 @@ jobs:
- 'tests/**'
frontend-check:
name: Frontend — CSS and JS checks (format, lint, rules)
name: Frontend — CSS and JS checks (format, lint, css-rules, types)
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
@@ -61,16 +61,20 @@ jobs:
node-version: 25
# because we are not using package.json
- name: Install Stylelint and plugins
- name: Install Stylelint, TypeScript and plugins
run: |
npm install --global \
stylelint@17 \
postcss@8 \
stylelint-value-no-unknown-custom-properties@6
stylelint-value-no-unknown-custom-properties@6 \
typescript
- name: Run Stylelint
run: npx stylelint "static/css/**/*.{css,scss}"
- name: Run TypeScript check
run: tsc -p jsconfig.json --noEmit
rust-fmt:
name: Rustfmt
needs: changes
+12
View File
@@ -130,6 +130,7 @@ Never duplicate logic across handlers or services. If the same behaviour is need
- Naming: `camelCase` for variables/functions, `PascalCase` for classes
- No `var` — use `const`/`let` only
- **JSDoc required** on all public functions — `jsconfig.json` enables `checkJs` globally (equivalent to `@ts-check` on every file)
- Always us static/js/core/types.js to mapp OxCcloud API structure
- Type parameters, return types, and complex types via `@typedef`:
```js
@@ -162,6 +163,17 @@ Never duplicate logic across JS modules. If the same behaviour is needed in more
- One CSS file per logical component in `/static/css/`
- [data-theme="dark"] is permitted only in /static/css/themes/dark.css
## Frontend Pre-commit checks
Always run these before committing, in this order:
```bash
biome check --fix # Auto-format
biome lint --fix # Lint (must pass)
stylelint static/css/ # Css rules
tsc -p jsconfig.json --noEmit # Ensure JS is always typed
```
# What Claude must NOT do
- Edit `Cargo.lock` directly
- Use npm dependencies not listed in this file
+155 -15
View File
@@ -173,6 +173,7 @@ fn resolve_css_imports(entry: &Path, css_dir: &Path) -> String {
if let Some(rel) = extract_import_path(t) {
let resolved = css_dir.join(rel.trim_start_matches("./"));
if resolved.exists() {
println!("cargo:warning=CSS importing: {}", resolved.display());
out.push_str(&fs::read_to_string(&resolved).unwrap_or_default());
out.push('\n');
} else {
@@ -238,6 +239,7 @@ fn minify_tree_css(dir: &Path) {
if fname.starts_with("app.") || fname == "main.css" {
continue;
}
println!("cargo:warning=CSS importing: {}", p.display());
if let Ok(src) = fs::read_to_string(&p) {
let _ = fs::write(&p, css_minify_safe(&src));
}
@@ -284,12 +286,23 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String
collect_module_deps(&path, &mut order, &mut seen);
}
println!(
"cargo:warning=bundle: {} files in dependency order:",
order.len()
);
let mut bundle = String::with_capacity(2 * 1024 * 1024);
bundle.push_str("(function(){\n\"use strict\";\n");
for file in &order {
let mut declared_namespaces = std::collections::HashSet::new();
for (i, file) in order.iter().enumerate() {
println!(
"cargo:warning=bundle [{:>3}/{}] {}",
i + 1,
order.len(),
file.display()
);
match fs::read_to_string(file) {
Ok(src) => {
bundle.push_str(&strip_esm_syntax(&src));
bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
bundle.push('\n');
}
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
@@ -326,7 +339,11 @@ fn collect_module_deps(
// Skip vendor bundles: they may use top-level await or other ESM
// patterns that are incompatible with IIFE wrapping. They must be
// loaded via dynamic import() at runtime instead.
if !target.components().any(|c| c.as_os_str() == "vendors") {
// Skip also workers path
if !target
.components()
.any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers")
{
collect_module_deps(&target, order, seen);
}
}
@@ -381,20 +398,82 @@ fn extract_from_clause(s: &str) -> Option<String> {
Some(rest[1..end].to_string())
}
/// Extract all names that a JS module source exports.
///
/// Handles:
/// - `export { X, Y };` and `export { X as Z };`
/// - `export function f`, `export async function f`, `export class C`
/// - `export const X`, `export let X`, `export var X`
///
/// Does NOT follow `export { X } from '...'` re-exports.
fn extract_exported_names(source: &str) -> Vec<String> {
let mut names = Vec::new();
for line in source.lines() {
let t = line.trim();
// export { X, Y } — skip re-exports from other modules
if (t.starts_with("export {") || t.starts_with("export{")) && !t.contains(" from ") {
if let (Some(start), Some(end)) = (t.find('{'), t.find('}')) {
for binding in t[start + 1..end].split(',') {
let b = binding.trim();
let exported = if let Some(pos) = b.find(" as ") {
b[pos + 4..].trim()
} else {
b
};
if !exported.is_empty() {
names.push(exported.to_string());
}
}
}
continue;
}
const DECL_PREFIXES: &[&str] = &[
"export async function ",
"export function ",
"export class ",
"export const ",
"export let ",
"export var ",
];
for prefix in DECL_PREFIXES {
if let Some(rest) = t.strip_prefix(prefix) {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if !name.is_empty() {
names.push(name);
}
break;
}
}
}
names
}
/// Strip ES-module syntax from a single file so it can be inlined into an IIFE.
///
/// | Input | Output |
/// |------------------------------------------|------------------------------|
/// | `import { X } from './y.js';` | *(empty line)* |
/// | `import { X as Y } from './y.js';` | `const Y = X;` |
/// | `export { X, Y };` | *(empty line)* |
/// | `export { X } from './y.js';` | *(empty line)* |
/// | `export const X = …` | `const X = …` |
/// | `export function f() {…}` | `function f() {…}` |
/// | `export async function f() {…}` | `async function f() {…}` |
/// | `export class C {…}` | `class C {…}` |
/// | `export default expr;` | `const _default = expr;` |
fn strip_esm_syntax(source: &str) -> String {
/// | Input | Output |
/// |------------------------------------------|---------------------------------------------|
/// | `import { X } from './y.js';` | *(empty line)* |
/// | `import { X as Y } from './y.js';` | `const Y = X;` |
/// | `import * as ns from './y.js';` | `const ns = { export1, export2, … };` |
/// | `export { X, Y };` | *(empty line)* |
/// | `export { X } from './y.js';` | *(empty line)* |
/// | `export const X = …` | `const X = …` |
/// | `export function f() {…}` | `function f() {…}` |
/// | `export async function f() {…}` | `async function f() {…}` |
/// | `export class C {…}` | `class C {…}` |
/// | `export default expr;` | `const _default = expr;` |
fn strip_esm_syntax(
source: &str,
file: &Path,
declared_namespaces: &mut std::collections::HashSet<String>,
) -> String {
let mut out = String::with_capacity(source.len());
// True while we are inside a multi-line import/export-list that has not yet
// seen its terminating `;`.
@@ -412,6 +491,66 @@ fn strip_esm_syntax(source: &str) -> String {
continue;
}
// ── import * as ns from './path.js' ───────────────────────────────────
// Build a synthetic namespace object from the module's exports so that
// `ns.foo()` calls resolve correctly inside the IIFE scope.
// If multiple files import the same namespace name, only the first
// declaration is emitted — subsequent ones become empty lines to avoid
// `SyntaxError: Identifier already declared`.
if t.starts_with("import * as ") {
let stmt = (|| -> Option<String> {
// Extract the namespace identifier
let after_as = t.strip_prefix("import * as ")?;
let name_end = after_as.find(' ')?;
let ns_name = &after_as[..name_end];
// Already declared earlier in the bundle — skip re-declaration.
if declared_namespaces.contains(ns_name) {
return Some(String::new());
}
// Extract the module path from the `from '…'` clause
let module_path = extract_from_clause(t)?;
if !module_path.starts_with('.') {
return None; // bare specifier — not bundled
}
// Skip vendor/worker bundles (dynamically loaded at runtime)
let base = file.parent().unwrap_or(Path::new("."));
let target = base.join(&module_path);
if target
.components()
.any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers")
{
return None;
}
let module_src = fs::read_to_string(&target).ok()?;
let exports = extract_exported_names(&module_src);
if exports.is_empty() {
return None;
}
declared_namespaces.insert(ns_name.to_string());
let indent = &line[..line.len() - line.trim_start().len()];
Some(format!(
"{}const {} = {{ {} }};",
indent,
ns_name,
exports.join(", ")
))
})();
match stmt {
Some(s) => out.push_str(&s),
None => {
println!("cargo:warning=bundle: could not resolve namespace import: {t}");
}
}
out.push('\n');
continue;
}
// ── import … ──────────────────────────────────────────────────────────
// Emit `const Y = X;` for any `import { X as Y }` aliases so that code
// using the aliased name still resolves inside the IIFE scope.
@@ -600,6 +739,7 @@ fn minify_tree_js(dir: &Path) {
continue;
}
if let Ok(src) = fs::read_to_string(&p) {
println!("cargo:warning=minify-js: {}", p.display());
let _ = fs::write(&p, js_minify_safe(&src));
}
}
+6 -3
View File
@@ -5,10 +5,13 @@
"allowJs": true,
"strict": true,
"noEmit": true,
"noImplicitAny": false,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictFunctionTypes": true,
"lib": ["ES2022", "DOM"],
// Treat all JS files as modules
"moduleDetection": "force",
@@ -17,6 +20,6 @@
"skipLibCheck": true,
"target": "ESNext"
},
"include": ["static/js/**/*.js"],
"exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs"]
"include": ["static/js/**/*.js" ],
"exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs", "static/js/vendors/**/*.js" ]
}
+4
View File
@@ -60,6 +60,10 @@ front-fmt:
front-lint:
biome lint static/
# test types (JSDOC), using typescript
front-type:
tsc -p jsconfig.json --noEmit
# check CSS rules
front-rules:
stylelint static/css/
+1 -7
View File
@@ -155,13 +155,7 @@ impl FolderUseCase for FolderService {
let folder = self
.folder_storage
.create_folder(dto.name, dto.parent_id)
.await
.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
format!("Failed to create folder: {}", e),
)
})?;
.await?;
// Convert to DTO
Ok(FolderDto::from(folder))
+2 -2
View File
@@ -841,8 +841,8 @@ impl AppServiceFactory {
tracing::warn!("╔══════════════════════════════════════════════════════════╗");
tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║");
tracing::warn!("║ ║");
tracing::warn!("║ Open the web UI to create the first admin account. ║");
tracing::warn!("║ The setup page is available until an admin is created. ║");
tracing::warn!("║ Open the web UI to create the first admin account. ║");
tracing::warn!("║ The setup page is available until an admin is created. ║");
tracing::warn!("╚══════════════════════════════════════════════════════════╝");
} else {
tracing::info!("System already initialized — setup endpoint disabled");
+1
View File
@@ -27,6 +27,7 @@
@import url("./components/search.css");
@import url("./components/icons.css");
@import url("./components/csp-utilities.css");
@import url("./components/pathTooltip.css");
/* Theme */
@import url("./themes/dark.css");
-1
View File
@@ -18,7 +18,6 @@
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css">
<link rel="stylesheet" href="/css/views/music.css">
<link rel="stylesheet" href="/css/components/pathTooltip.css">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
<script defer type="module" src="/js/core/i18n.js"></script>
+10
View File
@@ -8,6 +8,14 @@ import { updateStorageUsageDisplay } from './main.js';
import { app } from './state.js';
import { ui } from './ui.js';
/**
* @import {User} from '../core/types.js'
*/
/**
*
* @returns {Promise<User | null>}
*/
async function refreshUserData() {
const USER_DATA_KEY = 'oxicloud_user';
@@ -25,6 +33,7 @@ async function refreshUserData() {
return null;
}
/** @type {User} */
const userData = await response.json();
console.log('Refreshed user data from server:', userData);
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
@@ -83,6 +92,7 @@ async function checkAuthentication() {
// Check session validity by calling /api/auth/me (cookie auto-sent)
console.log('Checking session via /api/auth/me...');
/** @type {User} */
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
// We have cached user data — render immediately, refresh in background
+10 -6
View File
@@ -9,14 +9,14 @@ import { app } from './state.js';
import { ui } from './ui.js';
import { uiNotifications } from './uiNotifications.js';
/** @import {FileInfo, FolderInfo} from '../core/types.js' */
/** @import {FileItem, FolderItem} from '../core/types.js' */
let isLoadingFiles = false;
/**
* getFolder information
* @param {string} id the id of the folder
* @returns {Promise<FolderInfo>}
* @returns {Promise<FolderItem>}
*/
async function getFolder(id) {
/** @type {HeadersInit} */
@@ -47,7 +47,7 @@ async function getFolder(id) {
async function rebuildBreadCrumb() {
/**
* Store the leaf (this is the current displayed folder)
* @type {FolderInfo | null}
* @type {FolderItem | null}
*/
let currentFolderInfo = null;
@@ -172,7 +172,11 @@ async function loadFiles(options = { insertHistory: true }) {
if (forceRefresh) {
url += `&force_refresh=true`;
if (requestOptions.headers) requestOptions.headers['X-Force-Refresh'] = 'true';
if (requestOptions.headers) {
const headers = new Headers(requestOptions.headers);
headers.set('X-Force-Refresh', 'true');
requestOptions.headers = headers;
}
console.log('Forcing complete refresh ignoring cache');
}
@@ -202,10 +206,10 @@ async function loadFiles(options = { insertHistory: true }) {
multiSelect.init(); // this will wire buttons & select-all-checkbox
}
/** @type {FolderInfo[]} */
/** @type {FolderItem[]} */
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
/** @type {FileInfo[]} */
/** @type {FileItem[]} */
const fileList = Array.isArray(listing.files) ? listing.files : [];
if (folderList.length === 0 && fileList.length === 0) {
+7 -2
View File
@@ -35,6 +35,10 @@ import { loadTrashItems } from './trashView.js';
import { ui } from './ui.js';
import { setupUserMenu } from './userMenu.js';
/**
* @import {User} from '../core/types.js'
*/
// Upload dropdown listener state (prevents accumulated listeners)
/** @type {((e: MouseEvent) => void) | null} */
let uploadDropdownDocumentClickHandler = null;
@@ -141,7 +145,7 @@ const ACTIONS_BAR_TEMPLATES = {
/**
*
* @param {string} mode
* @param {'files' | 'trash' | 'favorites' | 'recent' | 'hidden'} mode
* @param {boolean} [force=false]
* @returns
*/
@@ -494,6 +498,7 @@ function setupEventListeners() {
ui.setupDragAndDrop();
// Debounce timer for live search
/** @type {ReturnType<typeof setTimeout>} */
let searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 3;
@@ -726,7 +731,7 @@ export function selectFolder(id, name) {
/**
* Update the storage usage display with the user's actual storage usage
* @param {Object} userData - The user data object
* @param {User} userData - The user data object
*/
function updateStorageUsageDisplay(userData) {
// Default values
-6
View File
@@ -136,12 +136,6 @@ export const SECTIONS_MAPPER = {
*/
function setCurrentSection(section) {
if (app.currentSection === section) return false;
// Set all view flags - true for active section, false for others
Object.entries(SECTIONS_MAPPER).forEach(([key, flag]) => {
app[flag] = key === section;
});
app.currentSection = section;
// Update nav item active classes by finding matching item from DOM
+11 -3
View File
@@ -8,9 +8,14 @@ import { app } from './state.js';
import { ui } from './ui.js';
/**
* @param {string} query
* @param {string} [sortBy]
* @import {SearchCriteria, SortByEnnum} from '../core/types.js'
*/
/**
* @param {string} query
* @param {SortByEnnum} [sortBy]
*/
// FIXME: refactor with search.js ?
async function performSearch(query, sortBy) {
console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`);
@@ -20,9 +25,11 @@ async function performSearch(query, sortBy) {
ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`);
/** @type {SearchCriteria} */
const options = {
recursive: true,
limit: 100,
offset: 0,
sort_by: sortBy || 'relevance'
};
@@ -51,7 +58,8 @@ document.addEventListener('search-resort', (e) => {
const event = /** @type {CustomEvent<{sort_by: string}>} */ (e);
const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
if (searchInput?.value.trim()) {
performSearch(searchInput.value.trim(), event.detail.sort_by);
const sortBy = /** @type {SortByEnnum} */ (event.detail.sort_by);
performSearch(searchInput.value.trim(), sortBy);
}
});
+37 -6
View File
@@ -3,39 +3,70 @@
* Centralized mutable state for app and cached DOM references.
*/
/** @import {FolderInfo} from '../core/types.js' */
/** @import {FileItem, FolderItem, LightItem} from '../core/types.js' */
export const app = {
currentView: 'grid',
/** @type {string | null} */
currentPath: '',
/** @type {string | null} */
currentFolder: null,
/** @type {FolderInfo | null} */
/** @type {FolderItem | null} */
currentFolderInfo: null,
/** @type {Object | null} */
/** @type {FolderItem | null} */
contextMenuTargetFolder: null,
/** @type {Object | null} */
/** @type {FileItem | null} */
contextMenuTargetFile: null,
selectedTargetFolderId: '',
moveDialogMode: 'file',
/** @type {string | null} */
moveDialogItemId: null,
/** @type {'file' | 'folder' | null} */
moveDialogItemMode: null,
/** @type {string | null} */
moveDialogCurrentFolderId: null,
/** @type {Array<{id: string, name: string}>} */
moveDialogBreadcrumb: [],
/** @type {FileItem[] | null} */
playlistDialogFiles: null,
/** @type {String | null} */
currentSection: null, // will be defined on first call
isSearchMode: false,
/** @type {FileItem | FolderItem | null} */
shareDialogItem: null,
/** @type {'file' | 'folder' | null} */
shareDialogItemType: null,
/** @type {String | null} */
notificationShareUrl: null,
/** @type {string | null} */
userHomeFolderId: null,
/** @type {string | null} */
userHomeFolderName: null,
/** @type {Object[]} */
/** @type {Array<{id: string, name: string}>} */
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
/** @type {String | null} */
viewFile: null // current file in inline view
viewFile: null, // current file in inline view
/** @type {LightItem[] | null} */
batchMoveItems: null
};
export const appElements = {
+10 -1
View File
@@ -9,6 +9,11 @@ import { multiSelect } from '../features/files/multiSelect.js';
import { appElements } from './state.js';
import { ui } from './ui.js';
/**
*
* @import {TrashItem} from '../core/types.js'
*/
async function loadTrashItems() {
const elements = appElements;
@@ -25,7 +30,7 @@ async function loadTrashItems() {
</div>
`;
ui.updateBreadcrumb('');
ui.updateBreadcrumb();
const trashItems = await fileOps.getTrashItems();
@@ -46,6 +51,10 @@ async function loadTrashItems() {
}
}
/**
*
* @param {TrashItem} item
*/
function addTrashItemToView(item) {
const elements = appElements;
const isFile = item.item_type === 'file';
+125 -53
View File
@@ -25,10 +25,17 @@ import { app } from './state.js';
import { uiFileTypes } from './uiFileTypes.js';
import { uiNotifications } from './uiNotifications.js';
/**
* @import {FileItem, FolderItem} from '../core/types.js'
* @import {BatchResult} from '../features/files/fileOperations.js'
*/
// UI Module
const ui = {
/** @type {HTMLDListElement | null} */
//dragPreview,
/** @type {HTMLDivElement | null} */
dragPreview: null,
/** @type {HTMLDivElement | null} */
draggedItems: null,
/**
* Initialize context menus and dialogs
@@ -338,21 +345,31 @@ const ui = {
const dropzone = document.getElementById('dropzone');
/**
*
* @param {DataTransfer} dataTransfer
* @returns {Promise<any[]|null>}
*/
const collectDroppedEntries = async (dataTransfer) => {
const items = Array.from(dataTransfer?.items || []);
const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean);
if (rootEntries.length === 0) return null;
/** @type {Array<{file: File, relativePath: string}>} */
const out = [];
/**
* @param {FileSystemEntry} entry
* @param {string} prefix
*/
const walkEntry = async (entry, prefix = '') => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file(
(file) => {
/** @type {FileSystemFileEntry} */ (entry).file(
(/** @type {File} */ file) => {
out.push({ file, relativePath: `${prefix}${file.name}` });
resolve(undefined);
},
@@ -364,7 +381,7 @@ const ui = {
if (entry.isDirectory) {
const dirPrefix = `${prefix}${entry.name}/`;
const reader = entry.createReader();
const reader = /** @type {FileSystemDirectoryEntry} */ (entry).createReader();
while (true) {
const children = await new Promise((resolve) => {
@@ -636,7 +653,7 @@ const ui = {
/**
* Check if a file can be previewed in the viewer
* @param {Object} file - File object with mime_type property
* @param {FileItem} file
* @returns {boolean}
*/
isViewableFile(file) {
@@ -647,6 +664,7 @@ const ui = {
* Get FontAwesome icon class for a filename based on its extension.
* Used as fallback when the backend DTO doesn't include icon_class
* (e.g. trash items).
* @param {string} fileName
*/
getIconClass(fileName) {
return uiFileTypes.getIconClass(fileName);
@@ -655,6 +673,7 @@ const ui = {
/**
* Get CSS special class for icon styling based on filename extension.
* Used as fallback when the backend DTO doesn't include icon_special_class.
* @param {string} fileName
*/
getIconSpecialClass(fileName) {
return uiFileTypes.getIconSpecialClass(fileName);
@@ -695,13 +714,13 @@ const ui = {
* Data store + event delegation (replaces per-item listeners)
* ================================================================ */
/** @type {Map<string, Object>} item data keyed by id */
/** @type {Map<string, FolderItem | FileItem>} item data keyed by id */
_items: new Map(),
/** @type {Array<Object>} last rendered folder dataset */
/** @type {FolderItem[]} last rendered folder dataset */
_lastFolders: [],
/** @type {Array<Object>} last rendered file dataset */
/** @type {FileItem[]} last rendered file dataset */
_lastFiles: [],
/** @type {boolean} */
@@ -716,9 +735,7 @@ const ui = {
},
/**
*
* @param {Object[]} folders
* @returns
* @param {FolderItem[]} folders
*/
_renderFoldersToView(folders) {
if (!Array.isArray(folders) || folders.length === 0) return;
@@ -727,15 +744,17 @@ const ui = {
const frag = document.createDocumentFragment();
for (const folder of folders) {
frag.appendChild(this._createFolderItem(folder));
try {
frag.appendChild(this._createFolderItem(folder));
} catch (e) {
console.warn(`Error building folder item `, folder, `reason: `, e);
}
}
target.appendChild(frag);
},
/**
*
* @param {Object[]} files
* @returns
* @param {FileItem[]} files
*/
_renderFilesToView(files) {
if (!Array.isArray(files) || files.length === 0) return;
@@ -744,11 +763,19 @@ const ui = {
const frag = document.createDocumentFragment();
for (const file of files) {
frag.appendChild(this._createFileItem(file));
try {
frag.appendChild(this._createFileItem(file));
} catch (e) {
console.warn(`Error building file item `, file, `reason: `, e);
}
}
target.appendChild(frag);
},
/**
* @param {any[]} arr
* @param {any} item
*/
_upsertById(arr, item) {
if (!Array.isArray(arr) || !item?.id) return;
const idx = arr.findIndex((x) => x && x.id === item.id);
@@ -796,6 +823,7 @@ const ui = {
await fileOps.moveFile(sourceId, targetFolderId);
*/
/** @type {BatchResult} */
let result;
switch (action) {
case 'copy':
@@ -843,6 +871,7 @@ const ui = {
this._delegationReady = true;
// ── helpers ────────────────────────────────────────────────
/** @param {HTMLDivElement} card */
const itemInfo = (card) => {
if (!card) return null;
const fileId = card.dataset.fileId;
@@ -864,6 +893,7 @@ const ui = {
return null;
};
/** @param {FileItem} file */
const openFile = async (file) => {
if (!file) return;
if (recent) {
@@ -897,6 +927,7 @@ const ui = {
}
};
/** @param {HTMLElement} card */
const navigateFolder = (card) => {
const folderId = card.dataset.folderId;
const folderName = card.dataset.folderName;
@@ -912,27 +943,31 @@ const ui = {
loadFiles();
};
/**
* @param {HTMLElement} card
* @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info
*/
const setContextTarget = (card, info) => {
if (info.type === 'folder') {
app.contextMenuTargetFolder = {
app.contextMenuTargetFolder = /** @type {FolderItem} */ ({
id: info.id,
name: card.dataset.folderName,
parent_id: card.dataset.parentId || ''
};
});
} else {
const fileData = info.data || this._items.get(info.id);
app.contextMenuTargetFile = {
const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id));
app.contextMenuTargetFile = /** @type {FileItem} */ ({
id: info.id,
name: card.dataset.fileName,
folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null
};
});
}
};
// ── click (open / navigate; select only via checkbox) ──
filesList.addEventListener('click', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return;
if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
@@ -975,7 +1010,7 @@ const ui = {
if (info.type === 'folder') {
navigateFolder(card);
} else {
openFile(info.data);
openFile(/** @type {FileItem} */ (info.data));
}
});
@@ -989,7 +1024,7 @@ const ui = {
// ── shared events ──────────────────────
filesList.addEventListener('contextmenu', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return;
e.preventDefault();
const info = itemInfo(card);
@@ -1008,7 +1043,7 @@ const ui = {
// dragstart
filesList.addEventListener('dragstart', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) {
e.preventDefault();
return;
@@ -1088,9 +1123,11 @@ const ui = {
// TODO better naming like ("selection in ${parent.name}") modulo i18n ? ...
const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-');
nameEncoded = `oxicloud ${now}.zip`;
/** @type {string[]} */
const folders = [];
/** @type {string[]} */
const files = [];
filesList.querySelectorAll(`div.selected`).forEach((e) => {
/** @type {NodeListOf<HTMLDivElement>} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => {
const item = itemInfo(e);
if (item.type === 'file') {
files.push(item.id);
@@ -1164,6 +1201,7 @@ const ui = {
* Favorite star helper – attaches a direct click handler to a
* star <button> so the event never bubbles to the card.
* ================================================================ */
/** @param {HTMLElement} el */
_bindStarClick(el) {
const star = el.querySelector('.favorite-star');
star?.addEventListener('click', (e) => {
@@ -1174,7 +1212,8 @@ const ui = {
if (!favorites) return;
// FIXME: make a function
const itemElement = shared?.closest('.file-item');
const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
@@ -1187,7 +1226,7 @@ const ui = {
favorites.removeFromFavorites(itemId, itemType);
} else {
this.setFavoriteVisualState(itemId, itemType, true);
favorites.addToFavorites(itemId, itemName, itemType);
favorites.addToFavorites(itemId, itemName, itemType, null);
}
// Keep context-menu label in sync if available
@@ -1201,26 +1240,30 @@ const ui = {
e.preventDefault();
// FIXME: make a function
const itemElement = shared?.closest('.file-item');
const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
// TODO corrently dirty
const item = {
const item = /** @type {unknown} */ ({
id: itemId,
item_id: itemId,
item_type: itemType,
item_name: itemName
};
});
contextMenus.showShareDialog(item, itemType);
contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
});
},
/**
* Sync favorite visuals for a file/folder across grid and list views.
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isFavorite
*/
setFavoriteVisualState(itemId, itemType, isFavorite) {
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1258,6 +1301,11 @@ const ui = {
}
},
/**
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isShared
*/
setSharedVisualState(itemId, itemType, isShared) {
console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`);
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1273,7 +1321,10 @@ const ui = {
* Element-creation helpers
* ================================================================ */
/** Create a list row for a folder */
/**
* Create a list row for a folder
* @param {FolderItem} folder
*/
_createFolderItem(folder) {
const el = document.createElement('div');
el.className = 'file-item';
@@ -1314,7 +1365,10 @@ const ui = {
return el;
},
/** Create a grid card for a file */
/**
* Create a grid card for a file
* @param {FileItem} file
*/
_createFileItem(file) {
const iconClass = file.icon_class || this.getIconClass(file.name);
const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name);
@@ -1425,7 +1479,7 @@ const ui = {
* Render an array of folders into both grid and list views
* using DocumentFragment for minimal reflows.
*
* @param {FolderInfo[]} folders
* @param {FolderItem[]} folders
*/
renderFolders(folders) {
if (!this._delegationReady) this.initDelegation();
@@ -1442,6 +1496,7 @@ const ui = {
/**
* Render an array of files into both grid and list views
* using DocumentFragment for minimal reflows.
* @param {FileItem[]} files
*/
renderFiles(files) {
if (!this._delegationReady) this.initDelegation();
@@ -1461,7 +1516,7 @@ const ui = {
/**
* Add a single folder to the active view.
* @param {Object} folder - Folder object
* @param {FolderItem} folder
*/
addFolderToView(folder) {
if (!this._delegationReady) this.initDelegation();
@@ -1472,6 +1527,7 @@ const ui = {
return;
}
this._clearEmptyState();
this._items.set(folder.id, folder);
this._upsertById(this._lastFolders, folder);
this._renderFoldersToView([folder]);
@@ -1479,7 +1535,7 @@ const ui = {
/**
* Add a single file to the active view.
* @param {Object} file - File object
* @param {FileItem} file
*/
addFileToView(file) {
if (!this._delegationReady) this.initDelegation();
@@ -1490,9 +1546,23 @@ const ui = {
return;
}
this._clearEmptyState();
this._items.set(file.id, file);
this._upsertById(this._lastFiles, file);
this._renderFilesToView([file]);
},
/**
* If the empty-state placeholder is showing, switch back to the file list.
* Called before adding any new item so the card is not appended to a hidden list.
*/
_clearEmptyState() {
const filesList = document.getElementById('files-list');
const filesContainerError = document.getElementById('files-container-error');
if (filesList?.classList.contains('hidden')) {
filesList.classList.remove('hidden');
filesContainerError?.classList.add('hidden');
}
}
};
@@ -1501,6 +1571,8 @@ const ui = {
/**
* Toggle selection state of a file/folder card.
* Routes through the multiSelect module so batch actions know about selected items.
* @param {HTMLDivElement} card
* @param {MouseEvent} event
*/
function toggleCardSelection(card, event) {
if (multiSelect) {
@@ -1512,6 +1584,8 @@ function toggleCardSelection(card, event) {
/**
* Show the context menu anchored next to a trigger element (the 3-dot button).
* @param {HTMLElement} triggerElement
* @param {string} menuId
*/
function showContextMenuAtElement(triggerElement, menuId) {
// Hide any open menus first
@@ -1542,8 +1616,6 @@ function showContextMenuAtElement(triggerElement, menuId) {
menu.classList.remove('hidden');
}
let __rubberBandJustFinished = false;
/**
* Rubber band (lasso) selection — click + drag on empty grid area
* to draw a rectangle and select all cards it touches.
@@ -1567,16 +1639,18 @@ function initRubberBandSelection() {
if (!container) return;
container.addEventListener('mousedown', (e) => {
if (!(e instanceof MouseEvent)) return;
// Only start if clicking empty area (not on a card, button, menu, input…)
if (e.button !== 0) return; // left click only
const target = /** @type {Element} */ (e.target);
if (
e.target.closest('.file-item') ||
e.target.closest('.context-menu') ||
e.target.closest('.upload-dropdown') ||
e.target.closest('button') ||
e.target.closest('input') ||
e.target.closest('.breadcrumb') ||
e.target.closest('.list-header')
target.closest('.file-item') ||
target.closest('.context-menu') ||
target.closest('.upload-dropdown') ||
target.closest('button') ||
target.closest('input') ||
target.closest('.breadcrumb') ||
target.closest('.list-header')
)
return;
@@ -1627,14 +1701,14 @@ function initRubberBandSelection() {
// Sync with multiSelect module
if (multiSelect) {
const info = multiSelect._extractInfo(card);
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.select(info.id, info.name, info.type, info.parentId);
}
} else {
card.classList.remove('selected');
// Deselect from multiSelect module
if (multiSelect) {
const info = multiSelect._extractInfo(card);
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.deselect(info.id);
}
}
@@ -1651,10 +1725,7 @@ function initRubberBandSelection() {
// Suppress the click event that follows mouseup so the global
// deselect handler doesn't immediately clear the selection.
if (hadSelection) {
__rubberBandJustFinished = true;
requestAnimationFrame(() => {
__rubberBandJustFinished = false;
});
requestAnimationFrame(() => {});
}
});
}
@@ -1709,6 +1780,7 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t
overlay.classList.add('active');
});
/** @param {boolean} result */
const cleanup = (result) => {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
+2 -2
View File
@@ -5,7 +5,7 @@
import { isTextViewable } from '../core/formatters.js';
/** @import {FileInfo} from '../core/types.js' */
/** @import {FileItem} from '../core/types.js' */
/** @type {Record<string, string>} */
const ICON_CLASS_MAP = {
@@ -158,7 +158,7 @@ const uiFileTypes = {
// TODO: 'd better to use a canViw() method in inlineViewer
/**
*
* @param {FileInfo} file
* @param {FileItem} file
* @returns {boolean}
*/
isViewableFile(file) {
+4
View File
@@ -18,6 +18,10 @@ function getCsrfToken() {
return match ? match.split('=')[1] : '';
}
/**
* returns headers to add, this includes the X-CSRF-Token
* @returns {Record<String, String>}
*/
function getCsrfHeaders() {
const token = getCsrfToken();
return token ? { 'X-CSRF-Token': token } : {};
+1
View File
@@ -37,6 +37,7 @@ const WRAPPER_USER_DATA_KEY = 'oxicloud_user';
let _originalFetch = window.fetch.bind(window);
/** Deduplicates concurrent refresh attempts into a single in-flight promise. */
/** @type {Promise<boolean> | null} */
let _refreshInFlight = null;
async function _refresh() {
+11 -1
View File
@@ -1,6 +1,7 @@
/**
* OxiCloud - Shared format and escaping utilities
* Centralized global helpers for date/size/text formatting and XSS-safe escaping.
* Contains also checkers
*/
/**
@@ -97,4 +98,13 @@ function isTextViewable(mimeType) {
return TEXT_TYPES.includes(mimeType);
}
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isTextViewable };
/**
* Chekif an email is valid
* @param {string} email
* @returns boolean
*/
function isEmailValid(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isEmailValid, isTextViewable };
+15 -7
View File
@@ -37,7 +37,7 @@ function resolveBrowserLocale() {
let currentLocale = resolveBrowserLocale();
// Cache for translations
/** @type {Record<string, Object>} */
/** @type {Record<string, any>} */
const translations = {};
/**
@@ -71,7 +71,7 @@ async function loadTranslations(locale) {
/**
* Get a nested translation value
* @param {object} obj - The translations object
* @param {Record<string, any>} obj - The translations object
* @param {string} path - The dot-notation path to the translation
* @returns {string|null} - The translation value or null if not found
*/
@@ -110,7 +110,7 @@ function getNestedValue(obj, path) {
/**
* Replace parameters in a translation string
* @param {string} text - The translation string with placeholders
* @param {object} params - The parameters to replace
* @param {Record<string, any>} params - The parameters to replace
* @returns {string} - The interpolated string
*/
function interpolate(text, params) {
@@ -244,11 +244,19 @@ document.addEventListener('DOMContentLoaded', async () => {
// Self-contained t wrapper — does NOT call the global t() because other
// scripts (e.g. admin.js) may shadow it, which would cause infinite recursion.
function safeT(key, params = {}) {
/**
* @param {string} key
* @param {string | Record<string, any>} [paramsOrFallback] - interpolation params object, or a string fallback used when the key is missing
* @returns {string}
*/
function safeT(key, paramsOrFallback = {}) {
const fallback = typeof paramsOrFallback === 'string' ? paramsOrFallback : null;
const params = typeof paramsOrFallback === 'object' ? paramsOrFallback : {};
const localeData = translations[currentLocale];
if (!localeData) {
// Translations not loaded yet — return humanised key suffix
return key.split('.').pop() || key;
// Translations not loaded yet — return fallback or humanised key suffix
return fallback ?? key.split('.').pop() ?? key;
}
let value = getNestedValue(localeData, key);
@@ -258,7 +266,7 @@ function safeT(key, params = {}) {
value = getNestedValue(translations.en, key);
}
if (!value) return key;
if (!value) return fallback ?? key;
return interpolate(value, params);
}
+2 -1
View File
@@ -15,6 +15,7 @@
// All icons use viewBox="0 0 {width} 512" and fill="currentColor".
// Keys use FA5 class names (without "fa-" prefix) for backward compatibility.
/** @type {Record<String, Array<number | String>>} */
const OxiIcons = {
'arrow-left': [
448,
@@ -503,7 +504,7 @@ function replaceIconsInElement(container) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', 'currentColor');
path.setAttribute('d', d);
path.setAttribute('d', /** @type {string} */ (d));
svg.appendChild(path);
el.replaceWith(svg);
+12 -1
View File
@@ -34,6 +34,10 @@ function getAvailableLanguages() {
const rtlLanguages = ['fa', 'ar'];
// Update HTML lang attribute and dir for RTL languages
/**
*
* @param {string} langCode
*/
function updateHtmlAttributes(langCode) {
const htmlElement = document.documentElement;
@@ -150,6 +154,7 @@ function createLanguageSelector(containerId = 'language-selector') {
/**
* Toggle dropdown open/closed
* @param {HTMLElement} container
*/
function toggleDropdown(container) {
const isOpen = container.classList.contains('open');
@@ -162,6 +167,7 @@ function toggleDropdown(container) {
/**
* Open dropdown
* @param {HTMLElement} container
*/
function openDropdown(container) {
container.classList.add('open');
@@ -173,6 +179,7 @@ function openDropdown(container) {
/**
* Close dropdown
* @param {HTMLElement} container
*/
function closeDropdown(container) {
container.classList.remove('open');
@@ -184,6 +191,8 @@ function closeDropdown(container) {
/**
* Select a language
* @param {String} langCode
* @param {HTMLElement} container
*/
async function selectLanguage(langCode, container) {
await i18n.setLocale(langCode);
@@ -197,6 +206,8 @@ async function selectLanguage(langCode, container) {
/**
* Update the UI to reflect selected language
* @param {String} langCode
* @param {HTMLElement} container
*/
function updateSelectedLanguage(langCode, container) {
const languages = getAvailableLanguages();
@@ -213,7 +224,7 @@ function updateSelectedLanguage(langCode, container) {
options.forEach((option) => {
const isActive = option.getAttribute('data-lang') === langCode;
option.classList.toggle('active', isActive);
option.setAttribute('aria-selected', isActive);
option.setAttribute('aria-selected', String(isActive));
});
}
+20 -18
View File
@@ -10,25 +10,25 @@ const Modal = {
// Modal element references
/** @private @type {HTMLElement | null} */
overlay: null,
// FIXME: unused ?
container: null,
/** @private @type {HTMLElement | null} */
icon: null,
/** @private @type {HTMLElement | null} */
title: null,
/** @private @type {HTMLElement | null} */
label: null,
/** @private @type {HTMLElement | null} */
/** @private @type {HTMLInputElement | null} */
input: null,
/** @private @type {HTMLElement | null} */
/** @private @type {HTMLButtonElement | null} */
cancelBtn: null,
/** @private @type {HTMLElement | null} */
/** @private @type {HTMLButtonElement | null} */
confirmBtn: null,
/** @private @type {HTMLElement | null} */
/** @private @type {HTMLButtonElement | null} */
closeBtn: null,
// Current callback
/** @private @type {Function | null} */
onConfirm: null,
/** @private @type {Function | null} */
onCancel: null,
/** @private @type {((value: string) => Promise<void>) | null} */
@@ -53,10 +53,10 @@ const Modal = {
this.icon = document.getElementById('modal-icon');
this.title = document.getElementById('modal-title');
this.label = document.getElementById('modal-label');
this.input = document.getElementById('modal-input');
this.cancelBtn = document.getElementById('modal-cancel-btn');
this.confirmBtn = document.getElementById('modal-confirm-btn');
this.closeBtn = document.getElementById('modal-close-btn');
this.input = /** @type {HTMLInputElement} */ (document.getElementById('modal-input'));
this.cancelBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-cancel-btn'));
this.confirmBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-confirm-btn'));
this.closeBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-close-btn'));
// Event listeners
this.errorEl = document.getElementById('modal-error');
@@ -106,13 +106,13 @@ const Modal = {
/**
* Show input modal (replacement for prompt())
* @param {Object} options - Modal configuration
* @param {string} options.title - Modal title
* @param {string} options.label - Input label
* @param {string} options.placeholder - Input placeholder
* @param {string} options.value - Initial input value
* @param {string} options.icon - Font Awesome icon class (e.g., 'fa-folder-plus')
* @param {string} options.confirmText - Confirm button text
* @param {string} options.cancelText - Cancel button text
* @param {string} [options.title] - Modal title
* @param {string} [options.label] - Input label
* @param {string} [options.placeholder] - Input placeholder
* @param {string} [options.value] - Initial input value
* @param {string} [options.icon] - Font Awesome icon class (e.g., 'fa-folder-plus')
* @param {string} [options.confirmText] - Confirm button text
* @param {string} [options.cancelText] - Cancel button text
* @param {(value: string) => Promise<void>} [options.action] - Async action called on confirm.
* Throw an Error to keep the modal open and display the error message inline.
* When omitted the modal resolves immediately with the input value (legacy behaviour).
@@ -218,6 +218,8 @@ const Modal = {
open() {
if (!this.overlay) return;
this.confirmBtn.disabled = false;
// Show overlay
this.overlay.classList.remove('hidden');
@@ -292,7 +294,7 @@ const Modal = {
if (this.onConfirm) this.onConfirm();
this.close(true);
} catch (e) {
this.showError(e.message || 'An error occurred');
this.showError(/** @type {Error} */ (e).message || 'An error occurred');
this.confirmBtn.disabled = false;
this.input.focus();
}
+25 -1
View File
@@ -15,13 +15,28 @@ import { i18n } from './i18n.js';
* clear()
*/
/**
* @typedef {Object} BatchNotification
* @property {HTMLElement} el
* @property {Number} totalFiles,
* @property {Number} completed
* @property {Number} successCount
* @property {Number} errorCount
* @property {Number} lastLabelUpdateTs
* @property {String} lastLabelFile
*/
const notifications = (() => {
/* ── state ──────────────────────────────────────────────── */
let _badgeCount = 0;
let _batchSeq = 0;
const _batches = {}; // batchId → { el, files:{}, totalFiles }
/** @type {Record<String,BatchNotification>} */
const _batches = {};
/* ── DOM refs (resolved lazily) ─────────────────────────── */
/** @type {(id: string) => HTMLElement | null } */
const $ = (id) => document.getElementById(id);
/* ── bell toggle ────────────────────────────────────────── */
@@ -237,6 +252,8 @@ const notifications = (() => {
/**
* Mark a file as completed within a batch (updates overall bar).
* DOM updates are throttled to every 5 files to avoid reflow starvation.
* @param {string} batchId
* @param {boolean} success
*/
function fileCompleted(batchId, success) {
const batch = _batches[batchId];
@@ -262,6 +279,9 @@ const notifications = (() => {
/**
* Finalise a batch – update icon and title.
* @param {string} batchId
* @param {number} successCount
* @param {number} totalFiles
*/
function finishBatch(batchId, successCount, totalFiles) {
const batch = _batches[batchId];
@@ -320,6 +340,10 @@ const notifications = (() => {
}
/* ── util ───────────────────────────────────────────────── */
// FIXME move to global library
/**
* @param {string} s
*/
function _esc(s) {
const d = document.createElement('div');
d.textContent = s;
+205 -5
View File
@@ -1,6 +1,20 @@
/**
* @typedef {Object} FolderInfo
* @property {string} category
* @typedef {'file' | 'folder'} ItemTypeEnum
*/
// FIXME to simplify
/**
* @typedef {Object} LightItem
* @property {string} id
* @property {string} name
* @property {ItemTypeEnum} type
* @property {string} parentId
*/
//FIXME: rename into FolderItem
/**
* @typedef {Object} FolderItem
* @property {string} category (folder)
* @property {number} created_at - timestamp
* @property {string} icon_class
* @property {string} icon_special_class
@@ -13,8 +27,9 @@
* @property {string} path the full path
*/
//FIXME: rename into FileItem
/**
* @typedef {Object} FileInfo
* @typedef {Object} FileItem
* @property {string} category
* @property {number} created_at - timestamp
* @property {string} icon_class
@@ -39,7 +54,7 @@
*/
/**
* @typedef {Object} Share
* @typedef {Object} ShareItem
* @property {number} access_count
* @property {number} created_at - timestamp
* @property {String} created_by
@@ -48,8 +63,193 @@
* @property {string} id
* @property {string} item_id
* @property {string} item_name
* @property {string} item_type
* @property {ItemTypeEnum} item_type
* @property {SharePermissions} permissions
* @property {string | null} token
* @property {string} url
*/
/**
* @typedef {Object} CreateShare
* @property {string} item_id
* @property {string|null} [item_name]
* @property {ItemTypeEnum} item_type
* @property {string|null} password
* @property {number|null} expires_at - timestamp
* @property {SharePermissions|null} permissions
*/
/**
* @typedef {Object} UpdateShare
* @property {string|null} password
* @property {number|null} expires_at - timestamp
* @property {SharePermissions|null} permissions
*/
/**
* @typedef {Object} FavoriteItem
* @property {string} id
* @property {string} user_id
* @property {string} item_id /// ID of the favorited item (file or folder)
* @property {ItemTypeEnum} item_type
* @property {number} created_at
* @property {string|null} item_name: null if folder
* @property {number|null} item_size null if folder
* @property {string|null} item_mime_type if file
* @property {string|null} parent_id
* @property {number|null} modified_at: Option<DateTime<Utc>>,
* @property {String} item_path Full human-readable path (e.g. "Documents/Work" for a folder, "Documents/Work/report.pdf" for a file)
* @property {String} icon_class
* @property {String} icon_special_class
* @property {String} category
* @property {String} size_formatted
*/
/**
* @typedef {Object} RecentItem
* @property {string} id
* @property {string} user_id
* @property {string} item_id /// ID of the favorited item (file or folder)
* @property {ItemTypeEnum} item_type
* @property {number} accessed_at
* @property {string|null} item_name: null if folder
* @property {number|null} item_size null if folder
* @property {string|null} item_mime_type if file
* @property {string|null} parent_id
* @property {String} item_path Full human-readable path (e.g. "Documents/Work" for a folder, "Documents/Work/report.pdf" for a file)
* @property {String} icon_class
* @property {String} icon_special_class
* @property {String} category
* @property {String} size_formatted
*/
/**
* @typedef {Object} TrashItem
* @property {string} id
* @property {string} original_id
* @property {ItemTypeEnum} item_type
* @property {string} name
* @property {string} original_path - timestamp
* @property {number} trashed_at
* @property {number} days_until_deletion
* @property {string} category
* @property {string} icon_class
* @property {string} icon_special_class
*/
/**
* @typedef {Object} User
* @property {string} id
* @property {string} username
* @property {string} email
* @property {string} role
* @property {number} storage_quota_bytes
* @property {number} storage_used_bytes
* @property {number} created_at
* @property {number} updated_at
* @property {number} last_login_at
* @property {boolean} active
* @property {string} auth_provider
*/
/**
* @typedef {Object} AuthResponse
* @property {User} user
* @property {String} access_token
* @property {String} refresh_token
* @property {String} token_type
* @property {number} expires_in
*/
/**
* @typedef {'user' | 'admin'} RoleEnum
*/
/**
* @typedef {"relevance" | "name" | "name_desc" | "date" | "date_desc" | "size" | "size_desc"} SortByEnnum
*/
/**
* @typedef {Object} SearchCriteria
* @property {SortByEnnum} sort_by
* @property {boolean} recursive
* @property {number} limit
* @property {number} offset
*
* @property {String} [name_contains]
* @property {String[]} [file_types] pdf, jpg, ...
* @property {String} [folder_id]
*
*
* @property {number} [min_size]
* @property {number} [max_size]
*
* @property {number} [created_before]
* @property {number} [created_after]
*
* @property {number} [modified_before]
* @property {number} [modified_after]
*/
/**
* @typedef {Object} SearchResults
* FIXME: is in fact Vec<SearchFileResultDto>,
* @property {FileItem[]} files
* FIXME: is infact Vec<SearchFolderResultDto>,
* @property {FolderItem[]} folders:
* @property {number | null} total_count
* @property {number} limit
* @property {number} offset
* @property {boolean} has_more
* @property {number} query_time_ms
* @property {string} sort_by
*/
/**
* @typedef {Object} Playlist
* @property {String} id
* @property {String} name
* @property {String | null} description
* @property {String} owner_id
* @property {boolean} is_public
* @property {String | null} cover_file_id
* @property {number} track_count
* @property {number} total_duration_secs
* @property {number} created_at
* @property {number} updated_at
*/
/**
* @typedef {Object} PlaylistItem
* @property {String} id
* @property {String} playlist_id
* @property {String} file_id
* @property {number} position
* @property {number} added_at
* @property {String|null} file_name
* @property {number|null} file_size
* @property {String|null} mime_type
* @property {String|null} title
* @property {String|null} artist
* @property {String|null} album
* @property {number|null} duration_secs
*/
/**
* @typedef {Object} Musicshare
* @property {String} user_id
* @property {boolean|null} can_write
*/
/**
* @typedef {Object} FileMetadata
* @property {String} file_id
* @property {number} captured_at
* @property {number|null} latitude
* @property {number|null} longitude
* @property {String|null} camera_make
* @property {String|null} camera_model
* @property {number|null} orientation
* @property {number|null} width
* @property {number|null} height
*/
+58 -5
View File
@@ -6,6 +6,10 @@
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
/**
* @import {AuthResponse, RoleEnum, User} from '../../core/types.js'
*/
// API endpoints
const API_URL = '/api/auth';
const LOGIN_ENDPOINT = `${API_URL}/login`;
@@ -41,6 +45,18 @@ function inputVal(id) {
}
// Language selector texts (used before i18n is loaded)
/**
* @typedef {Object} PreTranslatedText
* @property {string} title
* @property {string} subtitle
* @property {string} continue
* @property {string} autodetected
* @property {string} moreLanguages
* @property {string} modalTitle
* @property {string} searchPlaceholder
*/
/** @type {Record<String,PreTranslatedText>} */
const LANGUAGE_TEXTS = {
en: {
title: 'Welcome!',
@@ -145,6 +161,16 @@ const LANGUAGE_TEXTS = {
// Complete language registry — add new languages here, they'll appear automatically
// `popular: true` languages show as cards on the main screen, the rest in the modal
/**
* @typedef {Object} Lang
* @property {string} code
* @property {string} name
* @property {string} nativeName
* @property {string} flag
* @property {boolean} popular
*/
/** @type {Lang[]} */
export const ALL_LANGUAGES = [
{
code: 'en',
@@ -377,9 +403,18 @@ export const ALL_LANGUAGES = [
// --- Panel visibility helpers ---
// The `.hidden` CSS class uses `display: none !important`, so inline
// `style.display` can never override it. Always toggle the class instead.
/**
*
* @param {HTMLElement} el
*/
function showPanel(el) {
if (el) el.classList.remove('hidden');
}
/**
*
* @param {HTMLElement} el
*/
function hidePanel(el) {
if (el) el.classList.add('hidden');
}
@@ -434,13 +469,18 @@ function detectBrowserLanguage() {
return ALL_LANGUAGES[0]; // fallback to English
}
// Build a language option element (card style)
/**
* Build a language option element (card style)
* @param {Lang} lang
* @param {boolean} isSelected
* @returns
*/
function buildLanguageCard(lang, isSelected) {
const item = document.createElement('div');
item.className = `lang-picker-item${isSelected ? ' selected' : ''}`;
item.setAttribute('data-lang', lang.code);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', isSelected);
item.setAttribute('aria-selected', String(isSelected));
item.innerHTML = `
<span class="lang-picker-item-flag">${lang.flag}</span>
<span class="lang-picker-item-name">${lang.nativeName}</span>
@@ -596,7 +636,10 @@ function initLanguageSelector() {
});
}
// Update language panel texts based on selected language
/**
* Update language panel texts based on selected language
* @param {string} lang
*/
function updateLanguagePanelTexts(lang) {
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
const titleEl = document.getElementById('language-title');
@@ -1089,6 +1132,9 @@ if (isLoginPage && adminSetupForm) {
/**
* Login with username and password
* @param {string} username
* @param {string} password
* @returns {Promise<AuthResponse>}
*/
async function login(username, password) {
try {
@@ -1126,8 +1172,9 @@ async function login(username, password) {
// Parse the JSON response
try {
/** @type {AuthResponse} */
const data = await response.json();
console.log('Login successful, received data');
console.log(`Login successful for user id ${data.user.id}, received data`);
return data;
} catch (jsonError) {
console.error('Error parsing login response:', jsonError);
@@ -1141,6 +1188,11 @@ async function login(username, password) {
/**
* Register a new user
* @param {string} username
* @param {string} email
* @param {string} password
* @param {RoleEnum} [role]
* @returns {Promise<User>}
*/
async function register(username, email, password, role = 'user') {
try {
@@ -1170,8 +1222,9 @@ async function register(username, email, password, role = 'user') {
// Parse the JSON response
try {
/** @type {User} */
const data = await response.json();
console.log('Registration successful, received data');
console.log(`Registration successful, user created: ${data.id}, received data`);
return data;
} catch (jsonError) {
console.error('Error parsing registration response:', jsonError);
+49 -15
View File
@@ -20,10 +20,19 @@ import { inlineViewer } from './inlineViewer.js';
import { multiSelect } from './multiSelect.js';
import { wopiEditor } from './wopiEditor.js';
/**
* @import {FolderItem, FileItem, ItemTypeEnum, Playlist} from '../../core/types.js'
*/
/** @type {EventListener | null} */
let _moveDialogEscapeHandler = null;
// Context Menus Module
const contextMenus = {
/**
* @param {string} optionId
* @param {boolean} isFavorite
*/
_setFavoriteOptionLabel(optionId, isFavorite) {
const option = document.getElementById(optionId);
if (!option) return;
@@ -308,11 +317,13 @@ const contextMenus = {
// Note: We don't use stopPropagation because all Escape handlers are on document level
// Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses
if (!_moveDialogEscapeHandler) {
_moveDialogEscapeHandler = (e) => {
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
this.closeMoveDialog();
_moveDialogEscapeHandler = /** @type {EventListener} */ (
(/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
this.closeMoveDialog();
}
}
};
);
document.addEventListener('keydown', _moveDialogEscapeHandler);
}
@@ -385,8 +396,8 @@ const contextMenus = {
/**
* Show move dialog for a file or folder
* @param {Object} item - File or folder object
* @param {string} mode - 'file' or 'folder'
* @param {FolderItem | FileItem} item - File or folder object
* @param {ItemTypeEnum} mode
*/
async showMoveDialog(item, mode) {
// Set mode
@@ -405,15 +416,15 @@ const contextMenus = {
// Start at the parent of the item being moved (so user sees siblings and can navigate)
let startFolderId = null;
let startFolderName = null;
if (mode === 'file' && item.folder_id) {
startFolderId = item.folder_id;
if (mode === 'file' && /** @type {FileItem} */ (item).folder_id) {
startFolderId = /** @type {FileItem} */ (item).folder_id;
// We need the folder name for breadcrumb - try to get it from current view
const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`);
if (folderEl) {
startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null;
}
} else if (mode === 'folder' && item.parent_id) {
startFolderId = item.parent_id;
} else if (mode === 'folder' && /** @type {FolderItem} */ (item).parent_id) {
startFolderId = /** @type {FolderItem} */ (item).parent_id;
} else {
// If item is at root level, start at user's home folder
startFolderId = app.userHomeFolderId || null;
@@ -492,6 +503,7 @@ const contextMenus = {
// The contents endpoint returns an array of child folders
// The fallback /api/folders returns root folders (home folder itself)
/** @type {FolderItem[]} */
const folders = Array.isArray(data) ? data : data.folders || [];
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
@@ -623,6 +635,9 @@ const contextMenus = {
/**
* Render breadcrumb navigation for move dialog
* @param {HTMLElement | null} container
* @param {Array<{id: string, name: string}>} breadcrumb
* @param {string | null} _currentFolderId
*/
_renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) {
if (!container) return;
@@ -666,7 +681,7 @@ const contextMenus = {
}
// Breadcrumb path
breadcrumb.forEach((segment, index) => {
breadcrumb.forEach((/** @type {{id: string, name: string}} */ segment, /** @type {number} */ index) => {
const separator = document.createElement('span');
separator.className = 'move-breadcrumb-separator';
separator.textContent = '>';
@@ -709,8 +724,8 @@ const contextMenus = {
/**
* Show share dialog for files or folders
* @param {Object} item - File or folder object
* @param {string} itemType - 'file' or 'folder'
* @param {FileItem | FolderItem} item - File or folder object
* @param {ItemTypeEnum} itemType
*/
async showShareDialog(item, itemType) {
try {
@@ -835,7 +850,7 @@ const contextMenus = {
btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').classList.add('hidden');
ui.setSharedVisualState(item.id, item.type, false);
ui.setSharedVisualState(item.id, itemType, false);
}
}
});
@@ -920,7 +935,7 @@ const contextMenus = {
}
// Update Item's shared badge
ui.setSharedVisualState(item.id, item.type, true);
ui.setSharedVisualState(item.id, itemType, true);
// Show success message
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
@@ -994,8 +1009,14 @@ const contextMenus = {
app.notificationShareUrl = null;
},
/** @type {String | null} */
_selectedPlaylistId: null,
/**
*
* @param {FileItem} file
* @returns
*/
async showPlaylistDialog(file) {
const dialog = document.getElementById('playlist-dialog');
const container = document.getElementById('playlist-select-container');
@@ -1031,6 +1052,7 @@ const contextMenus = {
const resp = await fetch('/api/playlists', { credentials: 'include' });
if (!resp.ok) throw new Error('Failed to load playlists');
/** @type {Playlist[]} */
const playlists = await resp.json();
this._renderPlaylistSelect(container, playlists);
} catch (err) {
@@ -1039,6 +1061,12 @@ const contextMenus = {
}
},
/**
*
* @param {HTMLElement} container
* @param {Playlist[]} playlists
* @returns
*/
_renderPlaylistSelect(container, playlists) {
container.innerHTML = '';
@@ -1124,6 +1152,12 @@ const contextMenus = {
this._selectedPlaylistId = null;
},
/**
*
* @param {string} str
* @returns
*/
//FIXME: move to common library
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+74 -31
View File
@@ -11,10 +11,18 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js';
/** @import {TrashItem} from '../../core/types.js' */
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/**
* Get authorization headers for API requests.
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
* @returns {Object} Headers object
* @returns {Record<String, String>} Headers object
*/
function getAuthHeaders() {
return { ...getCsrfHeaders() };
@@ -25,15 +33,26 @@ const fileOps = {
// ========================================================================
// Upload progress — notification bell integration
// ========================================================================
/** @type {string | null} */
_currentBatchId: null,
/** @type {boolean} */
_isUploading: false, // Guard against concurrent upload calls
/** Start a new upload batch in the notification bell */
/**
* Start a new upload batch in the notification bell
* @param {number} totalFiles
* @param {string} [folderName]
*/
_initUploadToast(totalFiles, folderName) {
this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName);
},
/** Finalise the batch in the notification bell */
/**
* Finalise the batch in the notification bell
* @param {number} successCount
* @param {number} totalFiles
* */
_finishUploadToast(successCount, totalFiles) {
if (this._currentBatchId) {
notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
@@ -44,6 +63,8 @@ const fileOps = {
* Some drag-and-drop sources can inject directory placeholders into
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
* when trying to send them as normal files.
* @param {File} file
* @returns {Promise<boolean>}
*/
_canReadFileBlob(file) {
return new Promise((resolve) => {
@@ -58,10 +79,24 @@ const fileOps = {
});
},
// FIXME: prefer exceptions for errors
/**
* @typedef {Object} UploadAnswer
* @property {boolean} ok
* @property {any} [data]
* @property {string} [errorMsg]
* @property {boolean} [isQuotaError]
* @property {boolean} [isTimeout]
*/
/**
* Upload a single file via XMLHttpRequest with progress events.
* Progress is reported to the notification bell via batchId + fileName.
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
* @param {FormData} formData
* @param {string} batchId
* @param {string} fileName
* @param {number} [timeoutMs=120000]
*/
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
return new Promise((resolve) => {
@@ -76,9 +111,16 @@ const fileOps = {
let lastProgressPctSent = -1;
let isSettled = false;
/** @type {ReturnType<typeof setTimeout>} */
let stallTimer = null;
/** @type {ReturnType<typeof setTimeout>} */
let hardTimer = null;
/**
*
* @param {number} pct
* @param {'uploading' | 'done' | 'error'} status
*/
const safeUpdateFile = (pct, status) => {
if (!notif || !batchId) return;
try {
@@ -88,6 +130,11 @@ const fileOps = {
}
};
/**
*
* @param {UploadAnswer} result
* @returns
*/
const finalize = (result) => {
if (isSettled) return;
isSettled = true;
@@ -219,6 +266,12 @@ const fileOps = {
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
*/
/**
*
* @param {*} formData
* @param {*} timeoutMs
* @returns {Promise<UploadAnswer>}
*/
async _uploadFileFetch(formData, timeoutMs = 60000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -254,11 +307,13 @@ const fileOps = {
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
return { ok: false, errorMsg, isQuotaError };
} catch (e) {
const isTimeout = e?.name === 'AbortError';
const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError';
return {
ok: false,
isTimeout,
errorMsg: isTimeout ? `Timeout after ${Math.round(timeoutMs / 1000)}s` : `Fetch upload failed: ${e?.message || 'network error'}`
errorMsg: isTimeout
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
: `Fetch upload failed: ${/** @type {Error} */ (e)?.message || 'network error'}`
};
} finally {
clearTimeout(timeoutId);
@@ -458,6 +513,7 @@ const fileOps = {
try {
// Filter unreadable entries
/** @type {Array<{file: File, relativePath: string}>} */
const validEntries = [];
for (const e of rawEntries) {
// eslint-disable-next-line no-await-in-loop
@@ -560,12 +616,18 @@ const fileOps = {
const TIMEOUT_MIN_MS = 10000; // floor for tiny files
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
/**
*
* @param {number} idx
* @returns
*/
const uploadOneFile = async (idx) => {
if (quotaStop) return;
const entry = validEntries[idx];
const file = entry.file;
const rel = entry.relativePath || file.name;
/** @type {UploadAnswer} */
let result = { ok: false, errorMsg: 'Unknown client error' };
try {
const parts = rel.split('/');
@@ -577,6 +639,7 @@ const fileOps = {
// but block on open(). Pre-read only 0-byte files into
// memory; files with size>0 are always regular files and
// go straight to FormData (zero extra memory copy).
/** @type {Blob} */
let uploadFile = file; // default: use original File
if (file.size === 0) {
try {
@@ -616,7 +679,7 @@ const fileOps = {
} catch (e) {
result = {
ok: false,
errorMsg: `Client exception: ${e?.message || 'unknown'}`
errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}`
};
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
}
@@ -823,12 +886,6 @@ const fileOps = {
}
},
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/**
* Move files & folders
* @param {string[]} fileIds - File IDs
@@ -941,18 +998,12 @@ const fileOps = {
return res.ok;
},
/**
* @typedef {Object} BatchCopyReturn
* @property {number} success
* @property {number} errors
*/
/**
* Copy files & folders
* @param {string[]} fileIds - File IDs
* @param {string[]} folderIds - Folder IDs
* @param {string} targetFolderId - Target folder ID
* @returns {Promise<BatchCopyReturn>} - Success status
* @returns {Promise<BatchResult>} - Success status
*/
async batchCopy(fileIds, folderIds, targetFolderId) {
// FIXME ensure not moving a folder into itself
@@ -1010,7 +1061,6 @@ const fileOps = {
* Rename a file
* @param {string} fileId - File ID
* @param {string} newName - New file name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/
async renameFile(fileId, newName) {
try {
@@ -1052,13 +1102,6 @@ const fileOps = {
* Rename a folder
* @param {string} folderId - Folder ID
* @param {string} newName - New folder name
* @returns {Promise<boolean>} - Success status
*/
/**
* Rename a folder
* @param {string} folderId - Folder ID
* @param {string} newName - New folder name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/
async renameFolder(folderId, newName) {
try {
@@ -1170,7 +1213,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) {
app.currentPath = '';
ui.updateBreadcrumb('');
ui.updateBreadcrumb();
}
loadFiles();
ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`);
@@ -1186,7 +1229,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) {
app.currentPath = '';
ui.updateBreadcrumb('');
ui.updateBreadcrumb();
}
loadFiles();
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
@@ -1205,7 +1248,7 @@ const fileOps = {
/**
* Get trash items
* @returns {Promise<Array>} - List of trash items
* @returns {Promise<Array<TrashItem>>} - List of trash items
*/
async getTrashItems() {
try {
@@ -1214,7 +1257,7 @@ const fileOps = {
});
if (response.ok) {
return await response.json();
return /** @type {TrashItem[]} */ (await response.json());
} else {
console.error('Error fetching trash items:', response.statusText);
return [];
+64 -32
View File
@@ -8,6 +8,8 @@ import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js';
import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */
class InlineViewer {
constructor() {
this.setupViewer();
@@ -93,6 +95,11 @@ class InlineViewer {
console.log('Inline viewer initialized');
}
/**
*
* @param {FileItem} file
* @returns
*/
async openFile(file) {
console.log('Opening file:', file);
@@ -115,7 +122,7 @@ class InlineViewer {
// Get container
const modal = document.getElementById('inline-viewer-modal');
const container = modal.querySelector('.inline-viewer-container');
const container = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-container'));
const title = modal.querySelector('.inline-viewer-title');
// Clear container
@@ -125,12 +132,12 @@ class InlineViewer {
title.textContent = file.name;
// Set controls visibility
const controls = modal.querySelector('.inline-viewer-controls');
const controls = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-controls'));
// Show viewer based on file type
if (isImage) {
// Show zoom controls
controls.style.display = 'flex';
controls.classList.remove('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -142,7 +149,7 @@ class InlineViewer {
this.createBlobUrlViewer(file, 'image', container, loader);
} else if (file.mime_type && file.mime_type === 'application/pdf') {
// Hide zoom controls for PDFs
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -152,9 +159,9 @@ class InlineViewer {
// Create PDF viewer using object tag with blob URL
this.createBlobUrlViewer(file, 'pdf', container, loader);
} else if (file.mime_type && this.isTextViewable(file.mime_type)) {
} else if (file.mime_type && isTextViewable(file.mime_type)) {
// Hide zoom controls for text files
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -166,7 +173,7 @@ class InlineViewer {
this.createTextViewer(file, container, loader);
} else if (file.mime_type?.startsWith('audio/')) {
// Hide zoom controls for audio
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -178,7 +185,7 @@ class InlineViewer {
this.createMediaViewer(file, 'audio', container, loader);
} else if (file.mime_type?.startsWith('video/')) {
// Hide zoom controls for video
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -190,7 +197,7 @@ class InlineViewer {
this.createMediaViewer(file, 'video', container, loader);
} else {
// Hide zoom controls for unsupported files
controls.style.display = 'none';
controls.classList.add('hidden');
// Show unsupported file message
const message = document.createElement('div');
@@ -209,12 +216,13 @@ class InlineViewer {
modal.classList.add('active');
}
// Check if a MIME type is text-viewable
isTextViewable(mimeType) {
return isTextViewable(mimeType);
}
// Creates a text viewer using authenticated fetch
/**
*
* @param {FileItem} file
* @param {HTMLDivElement} container
* @param {*} loader
*/
async createTextViewer(file, container, loader) {
try {
console.log('Creating text viewer for:', file.name);
@@ -253,14 +261,20 @@ class InlineViewer {
}
}
// Creates a viewer using a Blob URL to avoid content-disposition header
async createBlobUrlViewer(file, type, container, loader) {
/**
* Creates a viewer using a Blob URL to avoid content-disposition header
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createBlobUrlViewer(file, mediaType, container, loader) {
try {
console.log('Creating blob URL viewer for:', file.name, 'type:', type);
console.log('Creating blob URL viewer for:', file.name, 'type:', mediaType);
// Update loader to show progress bar for large files
let progressBar = null;
let progressText = null;
let progressBar = /** @type {HTMLElement|null} */ (null);
let progressText = /** @type {HTMLElement|null} */ (null);
if (loader && file.size > 10 * 1024 * 1024) {
// Show progress for files > 10MB
loader.innerHTML = `
@@ -272,8 +286,8 @@ class InlineViewer {
<div class="inline-viewer-progress-text">0%</div>
</div>
`;
progressBar = loader.querySelector('.inline-viewer-progress-fill');
progressText = loader.querySelector('.inline-viewer-progress-text');
progressBar = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-fill'));
progressText = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-text'));
}
// Use XMLHttpRequest instead of fetch to get better control over the response
@@ -322,7 +336,7 @@ class InlineViewer {
loader.parentNode.removeChild(loader);
}
if (type === 'image') {
if (mediaType === 'image') {
console.log('Creating image viewer');
// Create image element
const img = document.createElement('img');
@@ -332,10 +346,10 @@ class InlineViewer {
container.appendChild(img);
// Add loading indicator until image loads
img.style.opacity = 0;
img.style.opacity = String(0);
img.onload = () => {
console.log('Image loaded successfully');
img.style.opacity = 1;
img.style.opacity = String(1);
};
img.onerror = () => {
@@ -343,7 +357,7 @@ class InlineViewer {
container.removeChild(img);
this.showErrorMessage(container);
};
} else if (type === 'pdf') {
} else if (mediaType === 'pdf') {
console.log('Creating PDF viewer');
// Create iframe for PDF (more reliable than object tag)
@@ -382,7 +396,13 @@ class InlineViewer {
}
}
// Creates an audio or video player using blob URL (authenticated fetch)
/**
* Creates an audio or video player using blob URL (authenticated fetch)
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createMediaViewer(file, mediaType, container, loader) {
try {
console.log(`Creating ${mediaType} player for:`, file.name);
@@ -485,7 +505,10 @@ class InlineViewer {
}
}
// Helper to show error message
/**
* Helper to show error message
* @param {HTMLDivElement} container
*/
showErrorMessage(container) {
// Show error message
const message = document.createElement('div');
@@ -505,7 +528,7 @@ class InlineViewer {
const modal = document.getElementById('inline-viewer-modal');
// stops audio/video before closing viewver
const media = modal.querySelector('audio, video');
const media = /** @type {HTMLMediaElement} */ (modal.querySelector('audio, video'));
if (media && !media.paused) media.pause();
// Hide modal
@@ -525,6 +548,10 @@ class InlineViewer {
this.currentFile = null;
}
/**
*
* @param {FileItem} file
*/
downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
.then((res) => {
@@ -544,9 +571,14 @@ class InlineViewer {
.catch((err) => console.error('Download error:', err));
}
/**
*
* @param {number} factor
* @returns
*/
zoomImage(factor) {
const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image');
const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return;
@@ -560,7 +592,7 @@ class InlineViewer {
scale = Math.max(0.1, Math.min(5.0, scale));
// Save scale
img.dataset.scale = scale;
img.dataset.scale = String(scale);
// Apply scale
img.style.transform = `scale(${scale})`;
@@ -568,12 +600,12 @@ class InlineViewer {
resetZoom() {
const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image');
const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return;
// Reset scale
img.dataset.scale = 1.0;
img.dataset.scale = String(1);
img.style.transform = 'scale(1.0)';
}
}
+69 -14
View File
@@ -9,8 +9,6 @@
// TODO: rename into selection-bar ?
// TODO: merge with photo part
// @ts-check
import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js';
@@ -19,8 +17,14 @@ import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js';
/**
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
* @import {BatchResult} from './fileOperations.js'
*/
const multiSelect = {
/** Currently selected items: Map<id, { id, name, type, parentId }> */
/** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
_selected: new Map(),
/** Last clicked index for Shift-range selection */
@@ -49,6 +53,12 @@ const multiSelect = {
// ── Helpers for i18n ────────────────────────────────────
/**
*
* @param {string} key
* @param {any} vars
* @returns
*/
_t(key, vars) {
const val = i18n.t(key, vars);
return val !== key ? val : null;
@@ -56,6 +66,14 @@ const multiSelect = {
// ── Selection state management ──────────────────────────
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
toggle(id, name, type, parentId) {
if (this._selected.has(id)) {
this._selected.delete(id);
@@ -65,10 +83,22 @@ const multiSelect = {
return true;
},
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
select(id, name, type, parentId) {
this._selected.set(id, { id, name, type, parentId });
},
/**
*
* @param {string} id
*/
deselect(id) {
this._selected.delete(id);
},
@@ -80,7 +110,7 @@ const multiSelect = {
el.classList.remove('selected');
});
document.querySelectorAll('.item-checkbox').forEach((cb) => {
cb.checked = false;
/** @type {HTMLInputElement} */ (cb).checked = false;
});
this._syncUI();
},
@@ -111,11 +141,13 @@ const multiSelect = {
* @return {ItemSelection}
*/
getSelection(targtFolderId) {
/** @type {Array<string>} */
const fileIds = [];
/** @type {Array<string>} */
const folderIds = [];
// TODO optimize & check if _selected is a better use
document.querySelectorAll(`div.file-item.selected`).forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
if (item.dataset.fileId) {
fileIds.push(item.dataset.fileId);
} else {
@@ -152,6 +184,10 @@ const multiSelect = {
// ── DOM helpers ─────────────────────────────────────────
/**
*
* @param {HTMLDivElement} el
*/
_selectElement(el) {
const info = this._extractInfo(el);
if (info) {
@@ -160,18 +196,32 @@ const multiSelect = {
}
},
/**
*
* @param {string} containerId
* @param {string} selector
* @returns {void}
*/
_selectAllInContainer(containerId, selector) {
const container = document.getElementById(containerId);
const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId));
if (!container) return;
container.querySelectorAll(selector).forEach((el) => {
/** @type {NodeListOf<HTMLDivElement>} */ (container.querySelectorAll(selector)).forEach((el) => {
this._selectElement(el);
});
},
/**
*
* @returns {HTMLDivElement[]}
*/
_getAllVisibleItems() {
return [...document.querySelectorAll('.file-item')];
return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]);
},
/**
* @param {HTMLDivElement} el
* @returns {LightItem}
*/
_extractInfo(el) {
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
return {
@@ -194,6 +244,10 @@ const multiSelect = {
// ── Click handler (shared by grid + list) ───────────────
/**
* @param {HTMLDivElement} el
* @param {MouseEvent} event
*/
handleToggleItem(el, event) {
const items = this._getAllVisibleItems();
const index = items.indexOf(el);
@@ -210,7 +264,7 @@ const multiSelect = {
const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`;
document.querySelectorAll(sel).forEach((e) => {
e.classList.add('selected');
const checkbox = e.querySelector('input[type="checkbox"]');
const checkbox = /** @type {HTMLInputElement} */ (e.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = true;
});
}
@@ -218,7 +272,7 @@ const multiSelect = {
} else {
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
el.classList.toggle('selected', nowSelected);
const checkbox = el.querySelector('input[type="checkbox"]');
const checkbox = /** @type {HTMLInputElement} */ (el.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = nowSelected;
}
this._lastClickedIndex = index;
@@ -271,13 +325,13 @@ const multiSelect = {
_syncItemCheckboxes() {
document.querySelectorAll('.file-item').forEach((el) => {
const cb = el.querySelector('.item-checkbox');
const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
if (cb) cb.checked = el.classList.contains('selected');
});
},
_syncSelectAllCheckbox() {
const cb = document.getElementById('select-all-checkbox');
const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
if (!cb) return;
const all = this._getAllVisibleItems();
if (all.length === 0) {
@@ -454,9 +508,10 @@ const multiSelect = {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
const target = /** @type {Element} */ (e.target);
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
// ctrl+a cmd+a
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if (selectAllCheckbox) selectAllCheckbox.checked = true;
+30 -18
View File
@@ -12,6 +12,10 @@ import { app } from '../../app/state.js';
import { ui } from '../../app/ui.js';
import { getAuthHeaders } from './fileOperations.js';
/**
* @import {SearchCriteria, SearchResults} from '../../core/types.js'}
*/
const search = {
/**
* Perform a search using query parameters.
@@ -19,25 +23,29 @@ const search = {
* relevance_score, icon_class, category, size_formatted, etc.
*
* @param {string} query - Search query
* @param {Object} options - Additional search options
* @returns {Promise<Object>} - Enriched search results from backend
* @param {SearchCriteria} [options] - Additional search options
* @returns {Promise<SearchResults>} - Enriched search results from backend
*/
async searchFiles(query, options = {}) {
async searchFiles(query, options) {
try {
const params = new URLSearchParams();
params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.recursive !== undefined) params.append('recursive', options.recursive);
if (options.file_types) params.append('type', options.file_types);
if (options.min_size) params.append('min_size', options.min_size);
if (options.max_size) params.append('max_size', options.max_size);
if (options.created_after) params.append('created_after', options.created_after);
if (options.created_before) params.append('created_before', options.created_before);
if (options.modified_after) params.append('modified_after', options.modified_after);
if (options.modified_before) params.append('modified_before', options.modified_before);
if (options.limit) params.append('limit', options.limit);
if (options.offset) params.append('offset', options.offset);
if (options.recursive !== undefined) params.append('recursive', String(options.recursive));
if (options.file_types) {
options.file_types.forEach((file_type) => {
params.append('type', file_type);
});
}
if (options.min_size) params.append('min_size', String(options.min_size));
if (options.max_size) params.append('max_size', String(options.max_size));
if (options.created_after) params.append('created_after', String(options.created_after));
if (options.created_before) params.append('created_before', String(options.created_before));
if (options.modified_after) params.append('modified_after', String(options.modified_after));
if (options.modified_before) params.append('modified_before', String(options.modified_before));
if (options.limit) params.append('limit', String(options.limit));
if (options.offset) params.append('offset', String(options.offset));
if (options.sort_by) params.append('sort_by', options.sort_by);
const url = `/api/search?${params.toString()}`;
@@ -46,6 +54,7 @@ const search = {
const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) {
/** @type {SearchResults} */
return await response.json();
} else {
let errorText = '';
@@ -66,7 +75,10 @@ const search = {
folders: [],
total_count: 0,
query_time_ms: 0,
sort_by: 'relevance'
sort_by: 'relevance',
limit: 0,
offset: 0,
has_more: false
};
}
},
@@ -76,15 +88,15 @@ const search = {
* Returns lightweight name suggestions without full search overhead.
*
* @param {string} query - Prefix to search for
* @param {Object} options - { folder_id, limit }
* @param {SearchCriteria} [options] - { folder_id, limit }
* @returns {Promise<Object>} - { suggestions: [...], query_time_ms }
*/
async getSuggestions(query, options = {}) {
async getSuggestions(query, options) {
try {
const params = new URLSearchParams();
params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.limit) params.append('limit', options.limit);
if (options.limit) params.append('limit', String(options.limit));
const url = `/api/search/suggest?${params.toString()}`;
const response = await fetch(url, { headers: getAuthHeaders() });
@@ -111,7 +123,7 @@ const search = {
* - query_time_ms: Server-side query execution time
* - sort_by: Active sort order
*
* @param {Object} results - Enriched search results from backend
* @param {SearchResults} results - Enriched search results from backend
*/
displaySearchResults(results) {
ui.resetFilesList(); // ensure also list visible & error hidden
+23 -4
View File
@@ -19,6 +19,7 @@ class WopiEditor {
/**
* Check if a file can be opened in a WOPI editor by extension.
* Fetches supported extensions from the server (cached after first call).
* @param {string} filename
*/
async canEdit(filename) {
const ext = filename.split('.').pop().toLowerCase();
@@ -28,6 +29,9 @@ class WopiEditor {
/**
* Open file in a modal overlay (default mode).
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/
async openInModal(fileId, fileName, action) {
action = action || 'edit';
@@ -38,6 +42,9 @@ class WopiEditor {
/**
* Open file in a new browser tab.
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/
async openInTab(fileId, fileName, action) {
action = action || 'edit';
@@ -53,6 +60,8 @@ class WopiEditor {
/**
* Fetch editor URL and WOPI token from the backend.
* @param {string} fileId
* @param {string} action
*/
async _getEditorUrl(fileId, action) {
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
@@ -68,6 +77,9 @@ class WopiEditor {
/**
* Some WOPI file types, such as PDFs, are view-only.
* If an edit request returns 422, retry once in view mode.
* @param {string} fileId
* @param {string} fileName
* @param {string} action
*/
async _getEditorUrlWithFallback(fileId, fileName, action) {
try {
@@ -81,6 +93,11 @@ class WopiEditor {
}
}
/**
* @param {string} fileName
* @param {string} action
* @param {any} error
*/
_shouldRetryInViewMode(fileName, action, error) {
if (action !== 'edit' || !error || !error.message) {
return false;
@@ -92,6 +109,8 @@ class WopiEditor {
/**
* Show the editor in a full-screen modal with iframe.
* @param {Record<string, any>} editorData
* @param {string} fileName
*/
_showModal(editorData, fileName) {
this.closeEditor();
@@ -160,13 +179,13 @@ class WopiEditor {
document.body.appendChild(modal);
// ESC key handler
this._escHandler = function (e) {
this._escHandler = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape') this.closeEditor();
}.bind(this);
};
document.addEventListener('keydown', this._escHandler);
// Fix 7: Listen for postMessage from the editor iframe
this._messageHandler = function (e) {
this._messageHandler = (/** @type {MessageEvent} */ e) => {
var data;
try {
data = JSON.parse(e.data);
@@ -183,7 +202,7 @@ class WopiEditor {
if (sp) sp.remove();
}
}
}.bind(this);
};
window.addEventListener('message', this._messageHandler);
form.submit();
+57 -21
View File
@@ -12,8 +12,10 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js';
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
const favorites = {
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
_cache: new Map(),
/** Whether the initial fetch from the server has completed */
@@ -25,6 +27,10 @@ const favorites = {
return { ...getCsrfHeaders() };
},
/**
* @param {string} id
* @param {string} type
*/
_cacheKey(id, type) {
return `${type}:${id}`;
},
@@ -33,6 +39,7 @@ const favorites = {
* Replace the entire in-memory cache from an array of FavoriteItemDto
* objects (as returned by the batch endpoint). Avoids an extra
* GET /api/favorites round-trip.
* @param {any[]} items
*/
_replaceCacheFromResponse(items) {
this._cache.clear();
@@ -68,6 +75,7 @@ const favorites = {
return;
}
/** @type {FavoriteItem[]} */
const items = await response.json();
this._cache.clear();
for (const item of items) {
@@ -85,6 +93,8 @@ const favorites = {
/**
* Synchronous check used by ui.js to paint star icons.
* @param {string} id
* @param {string} type
*/
isFavorite(id, type) {
return this._cache.has(this._cacheKey(id, type));
@@ -92,6 +102,10 @@ const favorites = {
/**
* Add an item to favourites (server-first).
* @param {string} id
* @param {string} name
* @param {string} type
* @param {string} _parentId
*/
async addToFavorites(id, name, type, _parentId) {
try {
@@ -121,6 +135,8 @@ const favorites = {
/**
* Remove an item from favourites (server-first).
* @param {string} id
* @param {string} type
*/
async removeFromFavorites(id, type) {
try {
@@ -178,31 +194,51 @@ const favorites = {
return;
}
/** @type {FolderItem[]} */
const folders = [];
/** @type {FileItem[]} */
const files = [];
for (const item of this._cache.values()) {
// TODO: cast objects, but for that need to review user_id vs owner_id...
if (item.item_type === 'folder') {
folders.push({
id: item.item_id,
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
modified_at: item.modified_at || item.created_at,
path: item.item_path || ''
});
folders.push(
// FIXME: better to grab the real values
/** @type {FolderItem} */ {
id: item.item_id,
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
category: 'folder',
created_at: item.created_at,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: item.user_id,
is_root: false
}
);
} else {
files.push({
id: item.item_id,
name: item.item_name || item.item_id,
folder_id: item.parent_id || '',
mime_type: item.item_mime_type,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
category: item.category,
size: item.item_size || 0,
size_formatted: item.size_formatted,
modified_at: item.modified_at || item.created_at,
path: item.item_path || ''
});
files.push(
// FIXME: better to grab the real values
/** @type {FileItem} */ {
id: item.item_id,
name: item.item_name || item.item_id,
folder_id: item.parent_id || '',
mime_type: item.item_mime_type,
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
category: item.category,
size: item.item_size || 0,
size_formatted: item.size_formatted,
modified_at: item.modified_at || item.created_at,
path: item.item_path || '',
owner_id: item.user_id,
created_at: item.created_at,
sort_date: item.created_at
}
);
}
}
if (folders.length) ui.renderFolders(folders);
+164 -33
View File
@@ -6,17 +6,27 @@ import { oxiIcon } from '../../core/icons.js';
import { Modal } from '../../core/modal.js';
import { notifications } from '../../core/notifications.js';
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
/**
* OxiCloud - Music Library View
* Playlist management with track listings and audio player
*/
const musicView = {
/** @type {Playlist[]} */
playlists: [],
/** @type {Playlist | null} */
currentPlaylist: null,
/** @type {PlaylistItem[]} */
currentTracks: [],
loading: false,
/** @type {HTMLDivElement | null} */
_container: null,
_initialized: false,
selected: new Set(),
@@ -72,11 +82,11 @@ const musicView = {
if (!resp.ok) throw new Error('Failed to load playlists');
this.playlists = await resp.json();
this.playlists = /** @type {Playlist[]} */ (await resp.json());
this._renderPlaylists();
} catch (err) {
console.error('Music load error:', err);
this._showError(err.message);
this._showError(/** @type {Error} */ (err).message);
} finally {
this.loading = false;
this._showLoading(false);
@@ -209,7 +219,7 @@ const musicView = {
)
.join('');
listEl.querySelectorAll('.music-playlist-item').forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (listEl.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.addEventListener('click', () => {
const id = item.dataset.id;
this._selectPlaylist(id);
@@ -269,6 +279,11 @@ const musicView = {
}
},
/**
*
* @param {string} playlistId
* @returns
*/
async _selectPlaylist(playlistId) {
const playlist = this.playlists.find((p) => p.id === playlistId);
if (!playlist) return;
@@ -308,13 +323,18 @@ const musicView = {
togglePublicBtn.classList.toggle('active', playlist.is_public);
}
document.querySelectorAll('.music-playlist-item').forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-playlist-item')).forEach((item) => {
item.classList.toggle('active', item.dataset.id === playlistId);
});
await this._loadPlaylistTracks(playlistId);
},
/**
*
* @param {string} playlistId
* @returns
*/
async _loadPlaylistTracks(playlistId) {
const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return;
@@ -333,7 +353,7 @@ const musicView = {
this._renderTracks();
} catch (err) {
console.error('Track load error:', err);
trackListEl.innerHTML = `<div class="music-error">${err.message}</div>`;
trackListEl.innerHTML = `<div class="music-error">${/** @type {Error} */ (err).message}</div>`;
}
},
@@ -385,7 +405,7 @@ const musicView = {
)
.join('')}
`;
trackListEl.querySelectorAll('.music-track').forEach((row) => {
/** @type {NodeListOf<HTMLDivElement>} */ (trackListEl.querySelectorAll('.music-track')).forEach((row) => {
row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx, 10);
// Toggle selection
@@ -449,6 +469,11 @@ const musicView = {
});
},
/**
*
* @param {number} idx
* @returns
*/
_playTrack(idx) {
if (!this.currentTracks[idx]) return;
@@ -487,8 +512,12 @@ const musicView = {
this._createPlaylist(name.trim());
},
/**
*
* @param {String} name
*/
async _createPlaylist(name) {
const createBtn = document.getElementById('music-create-playlist-btn');
const createBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-create-playlist-btn'));
if (createBtn) createBtn.disabled = true;
try {
const resp = await fetch('/api/playlists', {
@@ -519,7 +548,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
} finally {
@@ -542,7 +571,7 @@ const musicView = {
});
if (!confirmed) return;
const deleteBtn = document.getElementById('music-delete-playlist-btn');
const deleteBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-delete-playlist-btn'));
if (deleteBtn) deleteBtn.disabled = true;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
@@ -568,7 +597,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
} finally {
@@ -576,6 +605,11 @@ const musicView = {
}
},
/**
*
* @param {number|null} secs
* @returns
*/
_formatDuration(secs) {
if (!secs) return '-';
const mins = Math.floor(secs / 60);
@@ -583,11 +617,20 @@ const musicView = {
return `${mins}:${s.toString().padStart(2, '0')}`;
},
/**
*
* @param {string|null} str
* @returns
*/
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
/**
*
* @param {boolean} show
*/
_showLoading(show) {
const existing = this._container?.querySelector('.music-loading');
if (show && !existing) {
@@ -600,6 +643,11 @@ const musicView = {
}
},
/**
*
* @param {string} message
* @returns
*/
_showError(message) {
if (!this._container) return;
this._container.innerHTML = `
@@ -647,7 +695,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -690,7 +738,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -731,8 +779,8 @@ const musicView = {
requestAnimationFrame(() => overlay.classList.add('active'));
const listEl = document.getElementById('music-picker-list');
const queryInput = document.getElementById('music-picker-query');
const addBtn = document.getElementById('music-picker-add-btn');
const queryInput = /** @type {HTMLInputElement} */ (document.getElementById('music-picker-query'));
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-picker-add-btn'));
const countEl = document.getElementById('music-picker-count');
const selectedIds = new Set();
@@ -765,6 +813,11 @@ const musicView = {
}
};
/**
*
* @param {FileItem[]} files
* @returns
*/
const renderFiles = (files) => {
if (files.length === 0) {
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${i18n.t('music.no_audio_files')}</div>`;
@@ -798,6 +851,7 @@ const musicView = {
};
// ── Debounced search ──
/** @type {ReturnType<typeof setTimeout> | null} */
let searchTimer = null;
queryInput.addEventListener('input', () => {
clearTimeout(searchTimer);
@@ -857,6 +911,12 @@ const musicView = {
fetchAudioFiles();
},
/**
*
* @param {string} _trackId
* @param {string} fileId
* @returns
*/
async _removeTrackFromPlaylist(_trackId, fileId) {
if (!this.currentPlaylist) return;
@@ -892,12 +952,18 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
},
/**
*
* @param {number} fromIdx
* @param {number} toIdx
* @returns
*/
async _reorderTrack(fromIdx, toIdx) {
if (!this.currentPlaylist) return;
@@ -923,7 +989,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
await this._loadPlaylistTracks(this.currentPlaylist.id);
@@ -967,8 +1033,8 @@ const musicView = {
});
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
const userInput = dialog.querySelector('#music-share-user-input');
const writeInput = dialog.querySelector('#music-share-write-input');
const userInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-user-input'));
const writeInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-write-input'));
const userId = userInput.value.trim();
if (!userId) return;
@@ -997,7 +1063,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1006,6 +1072,11 @@ const musicView = {
this._loadSharesList(dialog);
},
/**
*
* @param {HTMLDivElement} dialog
* @returns
*/
async _loadSharesList(dialog) {
if (!this.currentPlaylist) return;
const body = dialog.querySelector('.music-shares-body');
@@ -1019,6 +1090,7 @@ const musicView = {
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load shares');
/** @type {Musicshare[]} */
const shares = await resp.json();
if (shares.length === 0) {
@@ -1040,16 +1112,22 @@ const musicView = {
body.querySelectorAll('.music-share-remove-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const item = btn.closest('.music-share-item');
const item = /** @type {HTMLDivElement} */ (btn.closest('.music-share-item'));
const userId = item.dataset.userId;
await this._removeShare(userId, dialog);
});
});
} catch (err) {
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(err.message)}</p>`;
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(/** @type {Error} */ (err).message)}</p>`;
}
},
/**
*
* @param {string} userId
* @param {HTMLDivElement} dialog
* @returns
*/
async _removeShare(userId, dialog) {
if (!this.currentPlaylist) return;
@@ -1067,7 +1145,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1115,7 +1193,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1183,7 +1261,7 @@ const musicView = {
icon: 'fa-exclamation-circle',
iconClass: 'error',
title: i18n.t('music.error'),
text: err.message
text: /** @type {Error} */ (err).message
});
}
}
@@ -1198,10 +1276,15 @@ const musicView = {
* Handles audio playback, queue, and controls
*/
const musicPlayer = {
/** @type {HTMLAudioElement | null} */
audio: null,
/** @type {PlaylistItem[]} */
queue: [],
currentIndex: -1,
/** @type {PlaylistItem|null} */
currentTrack: null,
isPlaying: false,
volume: 0.7,
isMuted: false,
@@ -1306,7 +1389,7 @@ const musicPlayer = {
const shuffleBtn = document.getElementById('player-shuffle-btn');
const repeatBtn = document.getElementById('player-repeat-btn');
const progressBar = document.getElementById('player-progress-bar');
const volumeInput = document.getElementById('player-volume-input');
const volumeInput = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
const volBtn = document.getElementById('player-vol-btn');
const playlistBtn = document.getElementById('player-playlist-btn');
const closeQueueBtn = document.getElementById('player-close-queue-btn');
@@ -1338,7 +1421,8 @@ const musicPlayer = {
if (volumeInput) {
volumeInput.addEventListener('input', (e) => {
this.setVolume(e.target.value / 100);
const target = /** @type {HTMLInputElement} */ (e.target);
this.setVolume(parseFloat(target.value) / 100);
});
}
@@ -1381,12 +1465,22 @@ const musicPlayer = {
document.body.classList.remove('music-player-active');
},
/**
*
* @param {PlaylistItem[]} tracks
* @param {string} playlistName
*/
setQueue(tracks, playlistName = '') {
this.queue = [...tracks];
this.playlistName = playlistName;
this._updateQueueUI();
},
/**
*
* @param {number} index
* @returns
*/
playTrack(index) {
if (index < 0 || index >= this.queue.length) return;
@@ -1488,15 +1582,19 @@ const musicPlayer = {
}
},
/**
*
* @param {number} vol
*/
setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol));
this.audio.volume = this.volume;
this.isMuted = this.volume === 0;
this._updateVolumeIcon();
const input = document.getElementById('player-volume-input');
const input = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
if (input) {
input.value = this.volume * 100;
input.value = String(this.volume * 100);
}
},
@@ -1522,6 +1620,11 @@ const musicPlayer = {
btn.querySelector('i').className = `fas ${icon}`;
},
/**
*
* @param {PointerEvent} e
* @returns
*/
_seek(e) {
const bar = document.getElementById('player-progress-bar');
if (!bar) return;
@@ -1596,6 +1699,10 @@ const musicPlayer = {
this._updateUI();
},
/**
*
* @param {ErrorEvent} e
*/
_onError(e) {
console.error('Audio error:', e);
this.isPlaying = false;
@@ -1624,7 +1731,8 @@ const musicPlayer = {
if (oxiIcon) {
icon.outerHTML = oxiIcon(iconName, extraClass);
} else {
icon.className = `fas fa-${iconName} ${extraClass}`;
icon.classList.remove(...icon.classList);
icon.classList.add('fas', `fa-${iconName}`, `${extraClass}`);
}
}
}
@@ -1640,7 +1748,7 @@ const musicPlayer = {
}
if (musicView.currentTracks.length > 0) {
document.querySelectorAll('.music-track').forEach((row) => {
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-track')).forEach((row) => {
const idx = parseInt(row.dataset.idx, 10);
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
@@ -1710,15 +1818,16 @@ const musicPlayer = {
)
.join('');
queueList.querySelectorAll('.player-queue-item').forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (queueList.querySelectorAll('.player-queue-item')).forEach((item) => {
item.addEventListener('click', (e) => {
if (e.target.closest('.queue-item-remove')) return;
const target = /** @type {Element} */ (e.target);
if (target.closest('.queue-item-remove')) return;
const idx = parseInt(item.dataset.idx, 10);
this.playTrack(idx);
});
});
queueList.querySelectorAll('.queue-item-remove').forEach((btn) => {
/** @type {NodeListOf<HTMLButtonElement>} */ (queueList.querySelectorAll('.queue-item-remove')).forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx, 10);
@@ -1727,6 +1836,10 @@ const musicPlayer = {
});
},
/**
*
* @param {number} idx
*/
_removeFromQueue(idx) {
if (idx === this.currentIndex) {
if (this.queue.length === 1) {
@@ -1754,6 +1867,10 @@ const musicPlayer = {
this._updateUI();
},
/**
*
* @param {boolean|undefined} [show]
*/
_toggleQueue(show) {
const queue = document.getElementById('player-queue');
if (queue) {
@@ -1765,6 +1882,11 @@ const musicPlayer = {
}
},
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatTime(secs) {
if (!secs || Number.isNaN(secs)) return '0:00';
const mins = Math.floor(secs / 60);
@@ -1772,10 +1894,19 @@ const musicPlayer = {
return `${mins}:${s.toString().padStart(2, '0')}`;
},
/**
*
* @param {number|null} secs
* @returns {String}
*/
_formatDuration(secs) {
return this._formatTime(secs);
},
/**
* @param {string|null} str
* @returns {String}
*/
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+48 -15
View File
@@ -8,10 +8,14 @@ import { i18n } from '../../core/i18n.js';
import { thumbnail } from '../thumbnail.js';
import { photosLightbox } from './photosLightbox.js';
/** @import {FileInfo} from '../../core/types.js' */
/** @import {FileItem} from '../../core/types.js' */
/**
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
*/
const photosView = {
/** @type {Array} All loaded photo items */
/** @type {Array<FileItem>} All loaded photo items */
items: [],
/** @type {string|null} Cursor for next page */
nextCursor: null,
@@ -27,7 +31,7 @@ const photosView = {
_container: null,
/** @type {boolean} */
_initialized: false,
/** @type {'daily'|'monthly'|'yearly'} */
/** @type {PhotoModeEnum} */
groupMode: 'monthly',
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
_videoThumbCache: new Map(),
@@ -84,6 +88,11 @@ const photosView = {
},
/** Switch grouping mode */
/**
*
* @param {PhotoModeEnum} mode
* @returns
*/
setGroupMode(mode) {
if (this.groupMode === mode) return;
this.groupMode = mode;
@@ -112,6 +121,7 @@ const photosView = {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
/** @type {FileItem[]} */
const data = await res.json();
if (!data || data.length === 0) {
@@ -178,7 +188,9 @@ const photosView = {
/** Append-only render for infinite scroll — inserts only the items
* from this.items[startIndex..] without destroying existing DOM.
* Complexity: O(batch) instead of O(total_items). */
* Complexity: O(batch) instead of O(total_items).
* @param {number} startIndex
*/
_appendBatch(startIndex) {
if (!this._container) return;
this._destroyObserver();
@@ -227,7 +239,10 @@ const photosView = {
this._setupVideoThumbnails(startIndex);
},
/** Generate HTML for a single photo/video tile */
/**
* Generate HTML for a single photo/video tile
* @param {FileItem} file
*/
_renderTile(file) {
const isVideo = file.mime_type?.startsWith('video/');
const selected = this.selected.has(file.id) ? ' selected' : '';
@@ -266,7 +281,7 @@ const photosView = {
/** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) {
const tiles = /** @type {NodeListOf<HTMLDivElement> */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const tiles = /** @type {NodeListOf<HTMLDivElement>} */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]'));
const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
if (!tiles) return;
@@ -290,11 +305,15 @@ const photosView = {
}
},
/** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */
/**
* Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate().
* @param {HTMLDivElement} tile
* @param {HTMLImageElement} img
*/
async _generateVideoThumbnail(tile, img) {
const fileId = tile.dataset.id;
// TODO: remove this HACK, this is not evolutive...
const file = /** @type {FileInfo} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
const file = /** @type {FileItem} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
try {
await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
@@ -335,7 +354,10 @@ const photosView = {
</div>`;
},
/** Group items by the current groupMode */
/**
* Group items by the current groupMode
* @param {FileItem[]} items
*/
_groupItems(items) {
const map = new Map();
for (const item of items) {
@@ -363,20 +385,24 @@ const photosView = {
return map;
},
/** Handle click on photo tile or toolbar */
/**
* Handle click on photo tile or toolbar
* @param {MouseEvent} e
*/
_handleClick(e) {
// Handle group mode toggle
const modeBtn = e.target.closest('[data-group-mode]');
const target = /** @type {Element} */ (e.target);
const modeBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-group-mode]'));
if (modeBtn) {
this.setGroupMode(modeBtn.dataset.groupMode);
this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
return;
}
const tile = e.target.closest('.photo-tile');
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
if (!tile) return;
const id = tile.dataset.id;
const check = e.target.closest('.photo-check');
const check = target.closest('.photo-check');
// If clicking checkbox or in selection mode, toggle select
if (check || this.selected.size > 0) {
@@ -391,7 +417,11 @@ const photosView = {
}
},
/** Toggle selection of an item */
/**
* Toggle selection of an item
* @param {string} id
* @param {HTMLDivElement} tile
*/
_toggleSelect(id, tile) {
if (this.selected.has(id)) {
this.selected.delete(id);
@@ -483,6 +513,7 @@ const photosView = {
if (bar) bar.style.display = 'none';
},
/** @param {boolean} show */
_showLoading(show) {
if (!this._container) return;
let loader = this._container.querySelector('.photos-loading');
@@ -503,12 +534,14 @@ const photosView = {
}
},
/** @param {any} s */
_escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
+39 -23
View File
@@ -6,8 +6,11 @@
import { getCsrfHeaders } from '../../core/csrf.js';
import { favorites } from '../library/favorites.js';
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
export const photosLightbox = {
/** @type {Array} Items array reference */
/** @type {Array<FileItem>} Items array reference */
items: [],
/** @type {number} Current index */
index: -1,
@@ -15,14 +18,14 @@ export const photosLightbox = {
_overlay: null,
/** @type {string|null} Current blob URL to revoke */
_blobUrl: null,
/** @type {Function|null} */
/** @type {(ev: KeyboardEvent) => any|null} */
_keyHandler: null,
/** @type {Object|null} Reference to photosView, set after both modules load */
/** @type {PhotosView|null} Reference to photosView, set after both modules load */
_photosView: null,
/**
* Register the photosView reference (called from photos.js to avoid circular imports).
* @param {Object} pv
* @param {any} pv
*/
setPhotosView(pv) {
this._photosView = pv;
@@ -33,7 +36,11 @@ export const photosLightbox = {
return getCsrfHeaders();
},
/** Open lightbox at given index */
/**
* Open lightbox at given index
* @param {FileItem[]} items
* @param {number} index
*/
open(items, index) {
this.items = items;
this.index = index;
@@ -99,21 +106,21 @@ export const photosLightbox = {
this._overlay = el;
// Event listeners
el.querySelector('.lightbox-close').onclick = () => this.close();
el.querySelector('.lightbox-prev').onclick = () => this.prev();
el.querySelector('.lightbox-next').onclick = () => this.next();
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
// Click backdrop to close
el.addEventListener('click', (e) => {
if (e.target === el || e.target.classList.contains('lightbox-content')) {
if (e.target === el || /** @type {HTMLElement} */ (e.target).classList.contains('lightbox-content')) {
this.close();
}
});
// Toolbar actions
el.querySelector('.lb-download').onclick = () => this._download();
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite();
el.querySelector('.lb-delete').onclick = () => this._delete();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
// Animate in
requestAnimationFrame(() => el.classList.add('active'));
@@ -144,8 +151,8 @@ export const photosLightbox = {
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
// Update nav button visibility
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden';
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden';
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
// Load content
this._revokeBlob();
@@ -176,7 +183,13 @@ export const photosLightbox = {
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
},
/** Load EXIF metadata for info bar */
/**
* Load EXIF metadata for info bar
* @param {string} fileId
* @param {Element} metaEl
* @param {string} dateStr
* @param {string} sizeStr
*/
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
try {
const res = await fetch(`/api/files/${fileId}/metadata`, {
@@ -184,16 +197,18 @@ export const photosLightbox = {
headers: this._headers()
});
if (res.ok) {
const data = await res.json();
const metadata = /** @type {FileMetadata} */ (await res.json());
const parts = [dateStr];
if (sizeStr) parts.push(sizeStr);
if (data.camera_make || data.camera_model) {
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' '));
if (metadata.camera_make || metadata.camera_model) {
parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
}
if (data.width && data.height) {
parts.push(`${data.width}×${data.height}`);
if (metadata.width && metadata.height) {
parts.push(`${metadata.width}×${metadata.height}`);
}
metaEl.textContent = parts.join(' · ');
//TODO: add geoloc pointer to openstreetmap ?
}
} catch (_err) {
// Non-critical, keep existing meta
@@ -220,7 +235,7 @@ export const photosLightbox = {
await fetch(`/api/favorites/file/${item.id}`, {
method: 'POST',
credentials: 'include',
headers: this._headers(true)
headers: this._headers()
});
const btn = this._overlay.querySelector('.lb-favorite');
if (btn) {
@@ -254,11 +269,11 @@ export const photosLightbox = {
this.items.splice(this.index, 1);
if (this.items.length === 0) {
this.close();
if (this._photosView) this._photosView._render();
if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
} else {
if (this.index >= this.items.length) this.index = this.items.length - 1;
this._show();
if (this._photosView) this._photosView._render();
if (this._photosView) this._photosView._renderFull();
}
} catch (err) {
console.error('Delete failed:', err);
@@ -289,6 +304,7 @@ export const photosLightbox = {
}
},
/** @param {any} s */
_escAttr(s) {
return String(s || '')
.replace(/"/g, '&quot;')
+28 -5
View File
@@ -12,6 +12,8 @@ import { i18n } from '../../core/i18n.js';
import { multiSelect } from '../files/multiSelect.js';
import * as pathTooltip from '../pathTooltip.js';
/** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */
const recent = {
/** Maximum items to request from the server */
MAX_RECENT_FILES: 20,
@@ -38,8 +40,9 @@ const recent = {
*/
setupEventListeners() {
document.addEventListener('file-accessed', (event) => {
if (event.detail?.file) {
const file = event.detail.file;
const e = /** @type {CustomEvent} */ (event);
if (e.detail?.file) {
const file = e.detail.file;
const itemType = file.item_type || 'file';
this._recordAccess(file.id, itemType);
}
@@ -48,6 +51,8 @@ const recent = {
/**
* Record an access event on the server.
* @param {string} itemId
* @param {ItemTypeEnum} itemType
*/
async _recordAccess(itemId, itemType) {
try {
@@ -90,7 +95,7 @@ const recent = {
throw new Error(`Server returned ${response.status}`);
}
const recentItems = await response.json();
const recentItems = /** @type {RecentItem[]} */ (await response.json());
ui.resetFilesList(); // ensure also list visible & error hidden
const filesList = document.getElementById('files-list');
@@ -120,8 +125,12 @@ const recent = {
`);
}
/** @type {FolderItem[]} */
const folders = [];
/** @type {FileItem[]} */
const files = [];
for (const item of recentItems) {
const isFolder = item.item_type === 'folder';
if (isFolder) {
@@ -130,9 +139,20 @@ const recent = {
name: item.item_name || item.item_id,
parent_id: item.parent_id || '',
modified_at: item.accessed_at,
path: item.item_path || ''
path: item.item_path || '',
category: 'folder',
created_at: item.accessed_at, //Wrong information
icon_class: item.icon_class,
icon_special_class: item.icon_special_class,
owner_id: '',
is_root: false
});
} else {
if (item.item_mime_type === undefined || item.item_mime_type === null) {
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
console.warn('Broken information for RecentItem: ', item);
//continue;
}
files.push({
id: item.item_id,
name: item.item_name || item.item_id,
@@ -144,7 +164,10 @@ const recent = {
size: item.item_size || 0,
size_formatted: item.size_formatted,
modified_at: item.accessed_at,
path: item.item_path || ''
path: item.item_path || '',
owner_id: '',
created_at: item.accessed_at, //wrong information
sort_date: item.accessed_at
});
}
}
+13 -2
View File
@@ -38,7 +38,13 @@ function _onLeave() {
_tooltip?.classList.add('hidden');
}
/** @type {WeakMap<HTMLElement, {enter: Function, leave: Function}>} */
/**
* @typedef {Object} EnterLeaveF
* @property {(e: MouseEvent) => void} enter
* @property {(e: MouseEvent) => void} leave
*
/** @type {WeakMap<HTMLElement, EnterLeaveF>} */
const _listeners = new WeakMap();
/**
@@ -49,10 +55,15 @@ function init(container) {
const items = container.querySelectorAll('.file-item[data-path]');
items.forEach((item) => {
const el = /** @type {HTMLElement} */ (item);
/** @type {(e: MouseEvent) => void} */
const enter = (e) => _onEnter(e);
const leave = () => _onLeave();
el.addEventListener('mouseenter', enter);
/** @type {(e: MouseEvent) => void} */
const leave = (_e) => _onLeave();
el.addEventListener('mouseleave', leave);
_listeners.set(el, { enter, leave });
});
}
+15 -8
View File
@@ -9,6 +9,10 @@ import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateTime } from '../../core/formatters.js';
/**
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
*/
const fileSharing = {
/** Auth header helper — tokens are in HttpOnly cookies now */
_headers(json = true) {
@@ -21,16 +25,17 @@ const fileSharing = {
* Create a shared link via backend API
* @param {string} itemId - ID of the file or folder
* @param {string} itemType - 'file' or 'folder'
* @param {Object} options - { name, password, expirationDate, permissions }
* @param {CreateShare} [options] -
* @returns {Promise<Object>} ShareDto from backend
*/
async createSharedLink(itemId, itemType, options = {}) {
// FIXME unused ?? duplicate with createSharedLink() from contextMenu
async createSharedLink(itemId, itemType, options) {
const body = {
item_id: itemId,
item_name: options.name || null,
item_name: options.item_name || null,
item_type: itemType,
password: options.password || null,
expires_at: options.expirationDate ? Math.floor(new Date(options.expirationDate).getTime() / 1000) : null,
expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null,
permissions: options.permissions || {
read: true,
write: false,
@@ -54,7 +59,7 @@ const fileSharing = {
/**
* Get all shared links for the current user
* @returns {Promise<Array>} Array of ShareDto
* @returns {Promise<ShareItem[]>} Array of ShareDto
*/
async getSharedLinks() {
try {
@@ -62,7 +67,7 @@ const fileSharing = {
headers: this._headers(false)
});
if (!res.ok) return [];
const data = await res.json();
const data = /** @type {ShareItem[]} */ await res.json();
return data.items || [];
} catch (error) {
console.error('Error fetching shared links:', error);
@@ -74,7 +79,7 @@ const fileSharing = {
* Get shared links for a specific item (server-side filtered)
* @param {string} itemId
* @param {string} itemType - 'file' or 'folder'
* @returns {Promise<Array>} Shares for this item
* @returns {Promise<ShareItem[]>} Shares for this item
*/
async getSharedLinksForItem(itemId, itemType) {
try {
@@ -96,6 +101,8 @@ const fileSharing = {
/**
* Check if an item has any shared links
* @param {string} itemId
* @param {string} itemType
* @returns {Promise<boolean>}
*/
async hasSharedLinks(itemId, itemType) {
@@ -106,7 +113,7 @@ const fileSharing = {
/**
* Update a shared link
* @param {string} shareId
* @param {Object} updateData - { permissions, password, expires_at }
* @param {UpdateShare} updateData - { permissions, password, expires_at }
* @returns {Promise<Object>} Updated ShareDto
*/
async updateSharedLink(shareId, updateData) {
+15 -10
View File
@@ -1,8 +1,11 @@
import { getCsrfHeaders } from '../core/csrf.js';
/** @import {FileInfo} from '../core/types.js' */
/** @import {FileItem} from '../core/types.js' */
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */
/**
* use any type so tsc will not scan library
* @type {any}
*/
let _pdfjsLib = null;
// TODO: do we need to add a max concurrncy ?
@@ -10,11 +13,13 @@ let _pdfjsLib = null;
/**
* Lazy-loads pdf.min.mjs on first use via dynamic import so it is never
* bundled into the IIFE (it uses top-level await which breaks IIFE wrapping).
* @returns {Promise<typeof import('../vendors/pdf.min.d.ts')>}
* @returns {Promise<any>}
*/
async function getPdfjsLib() {
if (_pdfjsLib) return _pdfjsLib;
_pdfjsLib = await import('/js/vendors/pdf.min.mjs');
// IMPORTANT: this hack (const lib=...) so tsc will not load vendors library
const lib = '../vendors/pdf.min.mjs';
_pdfjsLib = /** @type {any} */ (await import(lib));
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
return _pdfjsLib;
}
@@ -23,7 +28,7 @@ export const thumbnail = {
SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//],
/**
*
* @param {Object} file
* @param {FileItem} file
* @returns {boolean}
*/
canHandle(file) {
@@ -109,7 +114,7 @@ export const thumbnail = {
/**
*
* @param {FileInfo} file
* @param {FileItem} file
* @param {string} source
* @returns {Promise<ImageBitmap>}
*
@@ -163,7 +168,7 @@ export const thumbnail = {
/**
* generateThumbnail and update image
*
* @param {Object} file the source of the image
* @param {FileItem} file the source of the image
* @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated
*
@@ -203,7 +208,7 @@ export const thumbnail = {
MAX_CONCURRENT: 3,
_activeGenerates: 0,
/** @type {Array<() => void>} */
/** @type {Array<(resolve: any) => void>} */
_generateQueue: [],
/**
@@ -211,7 +216,7 @@ export const thumbnail = {
* At most MAX_CONCURRENT generations run simultaneously; excess calls are
* queued and resume automatically as slots free up.
*
* @param {FileInfo} file
* @param {FileItem} file
* @param {((dataURL: string) => void) | null} [onIconGenerated]
* @param {((dataURL: string) => void) | null} [onPreviewGenerated]
* @returns {Promise<void>}
@@ -224,7 +229,7 @@ export const thumbnail = {
try {
await this._generate(file, onIconGenerated, onPreviewGenerated);
} catch (err) {
if (err instanceof Event) {
if (err instanceof Event && 'error' in err.target) {
console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error);
} else if (err instanceof Error) {
console.warn(`generation of thumbnail for ${file.name} failed: `, err.message);
+4
View File
@@ -0,0 +1,4 @@
declare module '*pdf.min.mjs' {
const pdfjsLib: any;
export = pdfjsLib;
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.mjs' {
const value: any;
export default value;
}
+169 -109
View File
@@ -3,13 +3,20 @@ import { escapeHtml } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { oxiIconsInit } from '../../core/icons.js';
/**
* @import {RoleEnum} from '../../core/types.js'
*/
const API = '/api';
let currentAdminId = '';
let usersPage = 0;
const PAGE_SIZE = 50;
let totalUsers = 0;
/** Escape a string for safe embedding inside a JS string literal within an HTML attribute. */
/**
* Escape a string for safe embedding inside a JS string literal within an HTML attribute.
* @param {string} s
*/
function _escJs(s) {
if (typeof s !== 'string') return '';
return s.replace(/[^\w .-]/g, (c) => {
@@ -17,6 +24,7 @@ function _escJs(s) {
});
}
/** @param {string} id */
function hideElement(id) {
const element = document.getElementById(id);
if (!element) return;
@@ -24,6 +32,10 @@ function hideElement(id) {
element.classList.add('hidden');
}
/**
* @param {string} id
* @param {string} [mode]
*/
function showElement(id, mode = 'block') {
const element = document.getElementById(id);
if (!element) return;
@@ -39,6 +51,7 @@ function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
}
/** @param {number} bytes */
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024,
@@ -47,11 +60,12 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
/** @param {string|null} dateStr */
function timeAgo(dateStr) {
if (!dateStr) return i18n.t('admin.never');
const d = new Date(dateStr);
const now = new Date();
const secs = Math.floor((now - d) / 1000);
const secs = Math.floor((now.getTime() - d.getTime()) / 1000);
if (secs < 60) return i18n.t('admin.just_now');
if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) });
@@ -60,6 +74,7 @@ function timeAgo(dateStr) {
}
/* ── Custom confirm modal ── */
/** @param {string} message */
function showConfirm(message) {
return new Promise((resolve) => {
const overlay = document.getElementById('confirm-modal');
@@ -70,6 +85,7 @@ function showConfirm(message) {
overlay.classList.remove('hidden');
overlay.classList.add('show-flex');
/** @param {any} result */
function cleanup(result) {
overlay.classList.remove('show-flex');
overlay.classList.add('hidden');
@@ -84,6 +100,7 @@ function showConfirm(message) {
function onNo() {
cleanup(false);
}
/** @param {Event} e */
function onOverlay(e) {
if (e.target === overlay) cleanup(false);
}
@@ -96,6 +113,10 @@ function showConfirm(message) {
/* ── Tab switching with fade animation ── */
let activeTabName = 'dashboard';
/**
* @param {string} name
* @param {Element|undefined} el
*/
function switchTab(name, el) {
if (name === activeTabName) return;
var oldTab = document.getElementById(`tab-${activeTabName}`);
@@ -158,7 +179,7 @@ async function loadDashboard() {
document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? i18n.t('admin.enabled') : i18n.t('admin.disabled');
if (typeof d.registration_enabled !== 'undefined') {
document.getElementById('ds-registration').checked = d.registration_enabled;
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = d.registration_enabled;
if (d.registration_enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex');
}
@@ -200,7 +221,7 @@ async function loadUsers() {
}
tbody.innerHTML = users
.map((u) => {
.map((/** @type {any} */ u) => {
const quotaPct = u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green';
const quotaText =
@@ -306,18 +327,18 @@ async function loadUsers() {
.join('');
// Set dynamic progress bar widths (CSP-safe via JS property)
document.querySelectorAll('.progress-fill[data-width]').forEach((el) => {
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.progress-fill[data-width]')).forEach((el) => {
el.style.width = `${el.dataset.width}%`;
el.removeAttribute('data-width');
});
// Wire up admin action buttons (replaces inline onclick handlers)
document.querySelectorAll('.admin-action-btn').forEach((btn) => {
/** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('.admin-action-btn')).forEach((btn) => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota));
else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname);
else if (action === 'toggle-role') toggleRole(btn.dataset.uid, btn.dataset.role);
else if (action === 'toggle-role') toggleRole(btn.dataset.uid, /** @type {RoleEnum} */ (btn.dataset.role));
else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true');
else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname);
});
@@ -326,12 +347,12 @@ async function loadUsers() {
const from = usersPage * PAGE_SIZE + 1;
const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers);
document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers });
document.getElementById('prev-btn').disabled = usersPage === 0;
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
/** @type {HTMLButtonElement} */ (document.getElementById('prev-btn')).disabled = usersPage === 0;
/** @type {HTMLButtonElement} */ (document.getElementById('next-btn')).disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
} catch (e) {
tbody.innerHTML =
'<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('admin.error_network', { message: e.message })) +
escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })) +
'</td></tr>';
}
}
@@ -349,6 +370,10 @@ function nextPage() {
}
}
/**
* @param {string} userId
* @param {RoleEnum} currentRole
*/
async function toggleRole(userId, currentRole) {
const newRole = currentRole === 'admin' ? 'user' : 'admin';
const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole }));
@@ -366,10 +391,14 @@ async function toggleRole(userId, currentRole) {
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: e.message }));
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
/**
* @param {string} userId
* @param {boolean} currentActive
*/
async function toggleActive(userId, currentActive) {
const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate');
const ok = await showConfirm(msg);
@@ -387,10 +416,14 @@ async function toggleActive(userId, currentActive) {
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: e.message }));
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
/**
* @param {string} userId
* @param {string} username
*/
async function deleteUser(userId, username) {
const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username }));
if (!ok) return;
@@ -408,17 +441,22 @@ async function deleteUser(userId, username) {
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: e.message }));
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
let quotaUserId = '';
/**
* @param {string} userId
* @param {string} username
* @param {number} currentQuota
*/
function openQuotaModal(userId, username, currentQuota) {
quotaUserId = userId;
document.getElementById('qm-username').textContent = username;
const gb = currentQuota / 1073741824;
document.getElementById('qm-unit').value = '1073741824';
document.getElementById('qm-value').value = gb > 0 ? Math.round(gb * 10) / 10 : 0;
/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value = '1073741824';
/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value = String(gb > 0 ? Math.round(gb * 10) / 10 : 0);
showElement('quota-modal', 'flex');
}
function closeQuotaModal() {
@@ -426,8 +464,8 @@ function closeQuotaModal() {
}
async function saveQuota() {
const val = parseFloat(document.getElementById('qm-value').value) || 0;
const unit = parseInt(document.getElementById('qm-unit').value, 10);
const val = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value) || 0;
const unit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value, 10);
const bytes = Math.round(val * unit);
try {
const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, {
@@ -445,33 +483,33 @@ async function saveQuota() {
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: e.message }));
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
function openCreateUserModal() {
document.getElementById('cu-username').value = '';
document.getElementById('cu-password').value = '';
document.getElementById('cu-email').value = '';
document.getElementById('cu-role').value = 'user';
document.getElementById('cu-quota-value').value = '1';
document.getElementById('cu-quota-unit').value = '1073741824';
/** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value = '';
/** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value = '';
/** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value = '';
/** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value = 'user';
/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value = '1';
/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value = '1073741824';
document.getElementById('cu-error').className = 'alert';
document.getElementById('cu-error').textContent = '';
showElement('create-user-modal', 'flex');
setTimeout(() => document.getElementById('cu-username').focus(), 100);
setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).focus(), 100);
}
function closeCreateUserModal() {
hideElement('create-user-modal');
}
async function submitCreateUser() {
const username = document.getElementById('cu-username').value.trim();
const password = document.getElementById('cu-password').value;
const email = document.getElementById('cu-email').value.trim() || null;
const role = document.getElementById('cu-role').value;
const quotaVal = parseFloat(document.getElementById('cu-quota-value').value) || 0;
const quotaUnit = parseInt(document.getElementById('cu-quota-unit').value, 10);
const username = /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value.trim();
const password = /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value;
const email = /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value.trim() || null;
const role = /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value;
const quotaVal = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value) || 0;
const quotaUnit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value, 10);
const quotaBytes = Math.round(quotaVal * quotaUnit);
const errorEl = document.getElementById('cu-error');
@@ -486,7 +524,7 @@ async function submitCreateUser() {
return;
}
const btn = document.getElementById('cu-submit');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('cu-submit'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`;
try {
@@ -512,7 +550,7 @@ async function submitCreateUser() {
errorEl.className = 'alert alert-error';
}
} catch (e) {
errorEl.textContent = i18n.t('admin.error_network', { message: e.message });
errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
errorEl.className = 'alert alert-error';
}
btn.disabled = false;
@@ -520,21 +558,25 @@ async function submitCreateUser() {
}
let resetPwUserId = '';
/**
* @param {string} userId
* @param {string} username
*/
function openResetPasswordModal(userId, username) {
resetPwUserId = userId;
document.getElementById('rp-username').textContent = username;
document.getElementById('rp-password').value = '';
/** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value = '';
document.getElementById('rp-error').className = 'alert';
document.getElementById('rp-error').textContent = '';
showElement('reset-pw-modal', 'flex');
setTimeout(() => document.getElementById('rp-password').focus(), 100);
setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).focus(), 100);
}
function closeResetPasswordModal() {
hideElement('reset-pw-modal');
}
async function submitResetPassword() {
const password = document.getElementById('rp-password').value;
const password = /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value;
const errorEl = document.getElementById('rp-error');
if (password.length < 8) {
errorEl.textContent = i18n.t('admin.error_password_short');
@@ -542,7 +584,7 @@ async function submitResetPassword() {
return;
}
const btn = document.getElementById('rp-submit');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('rp-submit'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`;
try {
@@ -560,13 +602,14 @@ async function submitResetPassword() {
errorEl.className = 'alert alert-error';
}
} catch (e) {
errorEl.textContent = i18n.t('admin.error_network', { message: e.message });
errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
errorEl.className = 'alert alert-error';
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`;
}
/** @param {boolean} enabled */
async function toggleRegistration(enabled) {
if (enabled) hideElement('registration-warning');
else showElement('registration-warning', 'flex');
@@ -578,29 +621,33 @@ async function toggleRegistration(enabled) {
body: JSON.stringify({ registration_enabled: enabled })
});
if (!resp.ok) {
document.getElementById('ds-registration').checked = !enabled;
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning');
const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
document.getElementById('ds-registration').checked = !enabled;
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
if (!enabled) showElement('registration-warning', 'flex');
else hideElement('registration-warning');
alert(i18n.t('admin.error_network', { message: e.message }));
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
document.getElementById('oidc-enabled').addEventListener('change', function () {
if (this.checked) showElement('oidc-form');
if (/** @type {HTMLInputElement} */ (this).checked) showElement('oidc-form');
else hideElement('oidc-form');
});
document.getElementById('disable-password').addEventListener('change', function () {
if (this.checked) showElement('password-warning', 'flex');
if (/** @type {HTMLInputElement} */ (this).checked) showElement('password-warning', 'flex');
else hideElement('password-warning');
});
/**
* @param {string} msg
* @param {string} type
*/
function showOidcStatus(msg, type) {
const el = document.getElementById('oidc-status');
el.textContent = msg;
@@ -613,12 +660,12 @@ function copyCallback() {
}
async function testConnection() {
const url = document.getElementById('issuer-url').value.trim();
const url = /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim();
if (!url) {
showOidcStatus('Enter an Issuer URL first', 'error');
return;
}
const btn = document.getElementById('discover-btn');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('discover-btn'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`;
const resultDiv = document.getElementById('discovery-result');
@@ -639,32 +686,32 @@ async function testConnection() {
'</dd><dt>Auth Endpoint</dt><dd>' +
escapeHtml(r.authorization_endpoint || '—') +
'</dd></dl></div>';
if (!document.getElementById('provider-name').value && r.provider_name_suggestion)
document.getElementById('provider-name').value = r.provider_name_suggestion;
if (!(/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value) && r.provider_name_suggestion)
/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = r.provider_name_suggestion;
} else {
resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`;
}
} catch (e) {
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`;
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`;
}
async function saveOidcSettings() {
const btn = document.getElementById('save-btn');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
const body = {
enabled: document.getElementById('oidc-enabled').checked,
issuer_url: document.getElementById('issuer-url').value.trim(),
client_id: document.getElementById('client-id').value.trim(),
client_secret: document.getElementById('client-secret').value || null,
scopes: document.getElementById('scopes').value.trim() || null,
auto_provision: document.getElementById('auto-provision').checked,
admin_groups: document.getElementById('admin-groups').value.trim() || null,
disable_password_login: document.getElementById('disable-password').checked,
provider_name: document.getElementById('provider-name').value.trim() || null
enabled: /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked,
issuer_url: /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim(),
client_id: /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value.trim(),
client_secret: /** @type {HTMLInputElement} */ (document.getElementById('client-secret')).value || null,
scopes: /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value.trim() || null,
auto_provision: /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked,
admin_groups: /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value.trim() || null,
disable_password_login: /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked,
provider_name: /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value.trim() || null
};
try {
const resp = await fetch(`${API}/admin/settings/oidc`, {
@@ -682,7 +729,7 @@ async function saveOidcSettings() {
showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error');
}
} catch (e) {
showOidcStatus(i18n.t('admin.error_network', { message: e.message }), 'error');
showOidcStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.save_btn'))}`;
@@ -701,20 +748,25 @@ const STORAGE_PRESETS = {
'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false },
};
/** @param {boolean} visible */
function toggleS3Form(visible) {
if (visible) showElement('storage-s3-form');
else hideElement('storage-s3-form');
}
function onStoragePresetChange() {
const preset = document.getElementById('storage-preset').value;
const p = STORAGE_PRESETS[preset];
const preset = /** @type {HTMLInputElement} */ (document.getElementById('storage-preset')).value;
const p = STORAGE_PRESETS[/** @type {keyof typeof STORAGE_PRESETS} */ (preset)];
if (!p) return;
if (p.endpoint) document.getElementById('storage-endpoint-url').value = p.endpoint;
if (p.region) document.getElementById('storage-region').value = p.region;
document.getElementById('storage-path-style').checked = p.pathStyle;
if (p.endpoint) /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = p.endpoint;
if (p.region) /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = p.region;
/** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = p.pathStyle;
}
/**
* @param {string} msg
* @param {string} type
*/
function showStorageStatus(msg, type) {
const el = document.getElementById('storage-status');
el.textContent = msg;
@@ -732,21 +784,23 @@ async function loadStorage() {
// Backend selector
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
r.checked = r.value === s.backend;
const input = /** @type {HTMLInputElement} */ (r);
input.checked = input.value === s.backend;
});
toggleS3Form(s.backend === 's3');
// S3 fields
document.getElementById('storage-endpoint-url').value = s.s3_endpoint_url || '';
document.getElementById('storage-bucket').value = s.s3_bucket || '';
document.getElementById('storage-region').value = s.s3_region || '';
document.getElementById('storage-access-key').value = '';
document.getElementById('storage-secret-key').value = '';
document.getElementById('storage-path-style').checked = s.s3_force_path_style;
/** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = s.s3_endpoint_url || '';
/** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value = s.s3_bucket || '';
/** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = s.s3_region || '';
/** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value = '';
/** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value = '';
/** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = s.s3_force_path_style;
// Secret hints
if (s.s3_access_key_set) {
document.getElementById('storage-access-key').placeholder = i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value';
/** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).placeholder =
i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value';
}
if (s.s3_secret_key_set) {
showElement('storage-secret-hint');
@@ -755,7 +809,7 @@ async function loadStorage() {
}
// ENV badges
(s.env_overrides || []).forEach((field) => {
/** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
const badge = document.getElementById(`badge-${field}`);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
});
@@ -766,7 +820,7 @@ async function loadStorage() {
document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—';
document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—';
} catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error');
showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
}
// Also load migration status
@@ -774,19 +828,19 @@ async function loadStorage() {
}
async function saveStorageSettings() {
const btn = document.getElementById('btn-save-storage');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save-storage'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
const backend = document.querySelector('input[name="storage-backend"]:checked').value;
const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
const body = {
backend,
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null,
s3_bucket: document.getElementById('storage-bucket').value.trim() || null,
s3_region: document.getElementById('storage-region').value.trim() || null,
s3_access_key: document.getElementById('storage-access-key').value || null,
s3_secret_key: document.getElementById('storage-secret-key').value || null,
s3_force_path_style: document.getElementById('storage-path-style').checked
s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
};
try {
@@ -804,26 +858,26 @@ async function saveStorageSettings() {
showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error');
}
} catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error');
showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`;
}
async function testStorageConnection() {
const btn = document.getElementById('btn-test-storage');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`;
const backend = document.querySelector('input[name="storage-backend"]:checked').value;
const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
const body = {
backend,
s3_endpoint_url: document.getElementById('storage-endpoint-url').value.trim() || null,
s3_bucket: document.getElementById('storage-bucket').value.trim() || null,
s3_region: document.getElementById('storage-region').value.trim() || null,
s3_access_key: document.getElementById('storage-access-key').value || null,
s3_secret_key: document.getElementById('storage-secret-key').value || null,
s3_force_path_style: document.getElementById('storage-path-style').checked
s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null,
s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null,
s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null,
s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null,
s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null,
s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked
};
try {
@@ -842,7 +896,7 @@ async function testStorageConnection() {
showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error');
}
} catch (e) {
showStorageStatus(i18n.t('admin.error_network', { message: e.message }), 'error');
showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`;
@@ -850,8 +904,13 @@ async function testStorageConnection() {
/* ── Migration ── */
/** @type {ReturnType<typeof setInterval> | null} */
let migrationPollTimer = null;
/**
* @param {string} msg
* @param {string} type
*/
function showMigrationMsg(msg, type) {
const el = document.getElementById('migration-status-msg');
el.textContent = msg;
@@ -859,6 +918,7 @@ function showMigrationMsg(msg, type) {
el.style.display = '';
}
/** @param {any} m */
function updateMigrationUI(m) {
// Status badge
const badge = document.getElementById('migration-status-badge');
@@ -937,7 +997,7 @@ async function loadMigrationStatus() {
}
async function startMigration() {
const btn = document.getElementById('btn-start-migration');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-start-migration'));
btn.disabled = true;
try {
const resp = await fetch(`${API}/admin/storage/migration/start`, {
@@ -954,7 +1014,7 @@ async function startMigration() {
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
}
} catch (e) {
showMigrationMsg(i18n.t('admin.error_network', { message: e.message }), 'error');
showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
}
btn.disabled = false;
}
@@ -992,7 +1052,7 @@ async function resumeMigration() {
}
async function verifyMigration() {
const btn = document.getElementById('btn-verify-migration');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-verify-migration'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`;
const resultDiv = document.getElementById('migration-verify-result');
@@ -1015,7 +1075,7 @@ async function verifyMigration() {
}
} catch (e) {
resultDiv.style.display = '';
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(e.message)}</div>`;
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`;
@@ -1036,7 +1096,7 @@ async function completeMigration() {
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
}
} catch (e) {
showMigrationMsg(i18n.t('admin.error_network', { message: e.message }), 'error');
showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
}
}
@@ -1064,21 +1124,21 @@ async function init() {
});
if (oidcResp.ok) {
const s = await oidcResp.json();
document.getElementById('oidc-enabled').checked = s.enabled;
/** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked = s.enabled;
if (s.enabled) showElement('oidc-form');
else hideElement('oidc-form');
document.getElementById('provider-name').value = s.provider_name || '';
document.getElementById('issuer-url').value = s.issuer_url || '';
document.getElementById('client-id').value = s.client_id || '';
document.getElementById('scopes').value = s.scopes || 'openid profile email';
document.getElementById('auto-provision').checked = s.auto_provision;
document.getElementById('admin-groups').value = s.admin_groups || '';
document.getElementById('disable-password').checked = s.disable_password_login;
/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = s.provider_name || '';
/** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value = s.issuer_url || '';
/** @type {HTMLInputElement} */ (document.getElementById('client-id')).value = s.client_id || '';
/** @type {HTMLInputElement} */ (document.getElementById('scopes')).value = s.scopes || 'openid profile email';
/** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked = s.auto_provision;
/** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value = s.admin_groups || '';
/** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked = s.disable_password_login;
if (s.disable_password_login) showElement('password-warning', 'flex');
else hideElement('password-warning');
document.getElementById('callback-url').textContent = s.callback_url;
if (s.client_secret_set) showElement('secret-hint');
(s.env_overrides || []).forEach((field) => {
/** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
const badge = document.getElementById(`badge-${field}`);
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
});
@@ -1128,7 +1188,7 @@ document.getElementById('tab-btn-storage').addEventListener('click', function ()
});
document.getElementById('ds-registration').addEventListener('change', function () {
toggleRegistration(this.checked);
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
});
document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal);
@@ -1151,8 +1211,8 @@ document.getElementById('rp-submit').addEventListener('click', submitResetPasswo
/* ── Storage event listeners ── */
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
r.addEventListener('change', function () {
toggleS3Form(this.value === 's3');
r.addEventListener('change', () => {
toggleS3Form(/** @type {HTMLInputElement} */ (r).value === 's3');
});
});
document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange);
+21 -5
View File
@@ -8,8 +8,10 @@ import { oxiIconsInit } from '../../core/icons.js';
var deviceInfo = document.getElementById('device-info');
var actionButtons = document.getElementById('action-buttons');
var errorText = document.getElementById('error-text');
var btnApprove = document.getElementById('btn-approve');
var btnDeny = document.getElementById('btn-deny');
var btnApprove = /** @type {HTMLButtonElement} */ (document.getElementById('btn-approve'));
var btnDeny = /** @type {HTMLButtonElement} */ (document.getElementById('btn-deny'));
/** @type {ReturnType<typeof setTimeout>} */
var debounceTimer = null;
var currentCode = '';
@@ -24,12 +26,13 @@ import { oxiIconsInit } from '../../core/icons.js';
// Auto-insert hyphen and lookup on input
codeInput.addEventListener('input', (e) => {
var val = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
const target = /** @type {HTMLInputElement} */ (e.target);
var val = target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
// Auto-insert hyphen after 4 chars
if (val.length === 4 && val.indexOf('-') === -1) {
val = `${val}-`;
}
e.target.value = val;
target.value = val;
errorText.classList.add('hidden');
// Debounce lookup
@@ -52,6 +55,11 @@ import { oxiIconsInit } from '../../core/icons.js';
handleAction('deny');
});
/**
*
* @param {string} code
* @returns
*/
async function lookupCode(code) {
try {
const resp = await fetch(`${API_BASE}/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
@@ -81,6 +89,10 @@ import { oxiIconsInit } from '../../core/icons.js';
}
}
/**
*
* @param {'approve' | 'deny'} action
*/
async function handleAction(action) {
btnApprove.disabled = true;
btnDeny.disabled = true;
@@ -109,10 +121,14 @@ import { oxiIconsInit } from '../../core/icons.js';
} catch (err) {
btnApprove.disabled = false;
btnDeny.disabled = false;
showError(err.message || 'Failed to process action.');
showError(/** @type {Error} */ (err).message || 'Failed to process action.');
}
}
/**
*
* @param {string} msg
*/
function showError(msg) {
errorText.textContent = msg;
errorText.classList.remove('hidden');
+1 -1
View File
@@ -6,7 +6,7 @@ if (!/^[0-9a-fA-F]+$/.test(token)) {
document.body.innerHTML = '<p>Invalid session token.</p>';
throw new Error('Invalid token format');
}
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`;
/** @type {HTMLFormElement} */ (document.getElementById('login-flow-form')).action = `/login/v2/flow/${token}`;
// Check if OIDC is available and configure SSO button
(async () => {
+32 -14
View File
@@ -4,10 +4,16 @@ import { oxiIconsInit } from '../../core/icons.js';
const API = '/api';
// TOOD: reuse common library
/**
* @returns {Record<string, string>}
*/
function headers() {
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
}
// TOOD: move to common library
/** @param {number} bytes */
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024,
@@ -16,11 +22,13 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
// TOOD: move to common library
/** @param {string | null | undefined} dateStr */
function timeAgo(dateStr) {
if (!dateStr) return i18n.t('profile.never');
const d = new Date(dateStr);
const now = new Date();
const secs = Math.floor((now - d) / 1000);
const now = Date.now();
const secs = Math.floor((now - d.valueOf()) / 1000);
if (secs < 60) return i18n.t('profile.just_now');
if (secs < 3600) return i18n.t('profile.minutes_ago', { n: Math.floor(secs / 60) });
if (secs < 86400) return i18n.t('profile.hours_ago', { n: Math.floor(secs / 3600) });
@@ -104,11 +112,12 @@ function showError() {
document.getElementById('auth-error').classList.remove('hidden');
}
/** @param {Event} e */
async function changePassword(e) {
e.preventDefault();
const currentPw = document.getElementById('current-password').value;
const newPw = document.getElementById('new-password').value;
const confirmPw = document.getElementById('confirm-password').value;
const currentPw = /** @type {HTMLInputElement} */ (document.getElementById('current-password')).value;
const newPw = /** @type {HTMLInputElement} */ (document.getElementById('new-password')).value;
const confirmPw = /** @type {HTMLInputElement} */ (document.getElementById('confirm-password')).value;
const statusEl = document.getElementById('pw-status');
if (newPw !== confirmPw) {
@@ -121,7 +130,7 @@ async function changePassword(e) {
return false;
}
const btn = document.getElementById('pw-submit');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('pw-submit'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('profile.updating'))}`;
@@ -138,7 +147,7 @@ async function changePassword(e) {
if (resp.ok) {
statusEl.innerHTML = `<div class="alert alert-success"><i class="fas fa-check-circle"></i> ${escapeHtml(i18n.t('profile.password_updated'))}</div>`;
document.getElementById('password-form').reset();
/** @type {HTMLFormElement} */ (document.getElementById('password-form')).reset();
} else {
const err = await resp.json().catch(() => ({}));
statusEl.innerHTML =
@@ -149,7 +158,7 @@ async function changePassword(e) {
} catch (err) {
statusEl.innerHTML =
'<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('profile.error_network', { message: err.message })) +
escapeHtml(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message })) +
'</div>';
}
@@ -162,10 +171,12 @@ async function changePassword(e) {
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
/** @param {{label: string, active?: boolean, id: string}} pw */
function isAutoPassword(pw) {
return AUTO_LABELS.includes(pw.label);
}
/** @param {{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}} pw */
function renderPwRow(pw) {
const tr = document.createElement('tr');
const label = document.createElement('td');
@@ -210,7 +221,9 @@ async function loadAppPasswords() {
return;
}
const data = await resp.json();
const passwords = data.app_passwords || data;
const passwords = /** @type {Array<{label: string, active?: boolean, id: string, created_at: string, last_used_at?: string}>} */ (
data.app_passwords || data
);
const userPws = passwords.filter((pw) => {
return !isAutoPassword(pw);
});
@@ -236,7 +249,7 @@ async function loadAppPasswords() {
autoSection.classList.add('hidden');
} else {
autoSection.classList.remove('hidden');
document.getElementById('app-pw-auto-count').textContent = autoPws.length;
document.getElementById('app-pw-auto-count').textContent = String(autoPws.length);
const autoTbody = document.getElementById('app-pw-auto-tbody');
autoTbody.innerHTML = '';
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
@@ -255,10 +268,10 @@ function toggleAutoPasswords() {
}
async function createAppPassword() {
const labelInput = document.getElementById('app-pw-label');
const labelInput = /** @type {HTMLInputElement} */ (document.getElementById('app-pw-label'));
const label = labelInput.value.trim();
const statusEl = document.getElementById('app-pw-status');
const btn = document.getElementById('app-pw-generate');
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('app-pw-generate'));
if (!label) {
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('profile.error_label_required'))}</div>`;
@@ -291,7 +304,7 @@ async function createAppPassword() {
labelInput.value = '';
loadAppPasswords();
} catch (err) {
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${err.message}</div>`;
statusEl.innerHTML = `<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ${/** @type {Error} */ (err).message}</div>`;
} finally {
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-plus"></i> ${escapeHtml(i18n.t('profile.generate'))}`;
@@ -309,6 +322,10 @@ function copyAppPassword() {
});
}
/**
* @param {string} id
* @param {string} label
*/
async function revokeAppPassword(id, label) {
if (!confirm(i18n.t('profile.confirm_revoke', { label: label }))) return;
try {
@@ -325,10 +342,11 @@ async function revokeAppPassword(id, label) {
alert(err.message || i18n.t('profile.error_revoke'));
}
} catch (err) {
alert(i18n.t('profile.error_network', { message: err.message }));
alert(i18n.t('profile.error_network', { message: /** @type {Error} */ (err).message }));
}
}
/** @param {string} str */
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str || '';
+114 -23
View File
@@ -1,4 +1,4 @@
import { oxiIconsInit } from '../../core/icons';
import { oxiIconsInit } from '../../core/icons.js';
/**
* publicShare.js — client-side logic for /s/{token}.
@@ -17,12 +17,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const $folder = document.getElementById('share-folder');
const $pwForm = document.getElementById('password-form');
const $pwInput = document.getElementById('password-input');
const $pwInput = /** @type {HTMLInputElement} */ (document.getElementById('password-input'));
const $pwError = document.getElementById('password-error');
const $fileName = document.getElementById('file-name');
const $fileMeta = document.getElementById('file-meta');
const $fileDl = document.getElementById('file-download');
const $fileDl = /** @type {HTMLAnchorElement} */ (document.getElementById('file-download'));
const $expiredMsg = document.getElementById('expired-message');
// ── Token from URL path (/s/{token}) ──────────────────────────
@@ -49,35 +49,49 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}
let rootDisplayName = 'Shared folder';
/**
* @param {'loading'|'password'|'expired'|'file'|'folder'} name
*/
function showState(name) {
for (const el of [$loading, $password, $expired, $file, $folder]) {
if (el) el.classList.add('hidden');
}
const target = {
/** @type {{ loading: HTMLElement|null, password: HTMLElement|null, expired: HTMLElement|null, file: HTMLElement|null, folder: HTMLElement|null }} */
const map = {
loading: $loading,
password: $password,
expired: $expired,
file: $file,
folder: $folder
}[name];
};
const target = map[name];
if (target) target.classList.remove('hidden');
document.body.classList.toggle('gallery-mode', name === 'folder');
}
// ── Utilities ─────────────────────────────────────────────────
/**
* @param {string|null|undefined} s
* @returns {string}
*/
function escapeHtml(s) {
return String(s == null ? '' : s).replace(
/[&<>"']/g,
(c) =>
/** @type {Record<string, string>} */
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[c]
})[c] ?? c
);
}
/**
* @param {number} bytes
* @returns {string}
*/
function formatSize(bytes) {
if (bytes == null || Number.isNaN(bytes)) return '';
if (bytes < 1024) return `${bytes} B`;
@@ -90,6 +104,10 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
/**
* @param {string|null|undefined} mime
* @returns {'image'|'video'|null}
*/
function mediaKind(mime) {
const m = (mime || '').toLowerCase();
if (m.startsWith('image/')) return 'image';
@@ -97,6 +115,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return null;
}
// ── Render share data ─────────────────────────────────────────
/**
* @param {any} data
*/
function renderShare(data) {
if (data.item_type === 'folder') {
rootDisplayName = data.item_name || 'Shared folder';
@@ -114,13 +135,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
.then((res) => {
if (res.ok) return res.json();
if (res.status === 401) {
return res.json().then((body) => {
if (body?.requiresPassword) {
showState('password');
return null;
return res.json().then(
/** @type {(body:any) => null} */ (body) => {
if (body?.requiresPassword) {
showState('password');
return null;
}
throw new Error('Unauthorized');
}
throw new Error('Unauthorized');
});
);
}
if (res.status === 410) {
showState('expired');
@@ -171,7 +194,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
// ── Folder gallery ────────────────────────────────────────────
/** @type {string | null} */
let currentFolderId = null;
/** @type {string | null} */
let currentFolderName = null;
function initFolderGallery() {
@@ -195,17 +220,33 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return m ? m[1] : null;
}
/**
* @param {string | null} folderId
* @returns {string}
*/
function listingUrl(folderId) {
return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`;
}
/**
* @param {string} fileId
* @returns {string}
*/
function fileUrl(fileId) {
return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`;
}
/**
* @param {string | null} folderId
* @returns {string}
*/
function zipUrl(folderId) {
return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`;
}
/** @type {AbortController | null} */
let currentLoadController = null;
/**
* @param {string | null} folderId
*/
function loadAndRender(folderId) {
if (currentLoadController) currentLoadController.abort();
const controller = new AbortController();
@@ -236,6 +277,10 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
});
}
/**
* @param {any} listing
* @param {string | null} folderId
*/
function renderGallery(listing, folderId) {
const isSubfolder = folderId !== null;
const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName;
@@ -243,7 +288,21 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const backHtml = isSubfolder ? '<a class="gallery-back" href="#" data-action="back"><i class="fas fa-arrow-left"></i> Back to share root</a>' : '';
const headerHtml = `<header class="gallery-header"><h2 class="gallery-title">${escapeHtml(title)}</h2><div class="gallery-actions">${backHtml}<div class="gallery-view-toggle" role="group"><button type="button" data-view="grid" aria-pressed="${viewMode === 'grid'}"><i class="fas fa-th-large"></i></button><button type="button" data-view="list" aria-pressed="${viewMode === 'list'}"><i class="fas fa-bars"></i></button></div><a class="gallery-zip btn-primary" href="${escapeHtml(zipUrl(folderId))}" download><i class="fas fa-file-archive"></i> Download ZIP</a></div></header>`;
const headerHtml = `
<header class="gallery-header">
<h2 class="gallery-title">${escapeHtml(title)}</h2>
<div class="gallery-actions">
${backHtml}
<div class="gallery-view-toggle" role="group">
<button type="button" data-view="grid" aria-pressed="${viewMode === 'grid'}"><i class="fas fa-th"></i></button>
<button type="button" data-view="list" aria-pressed="${viewMode === 'list'}"><i class="fas fa-bars"></i></button>
</div>
<a class="gallery-zip btn-primary" href="${escapeHtml(zipUrl(folderId))}" download>
<i class="fas fa-file-archive"></i>
Download ZIP
</a>
</div>
</header>`;
const foldersHtml =
listing.folders && listing.folders.length > 0
@@ -252,7 +311,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
const filesHtml =
listing.files && listing.files.length > 0
? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((f) => fileCardHtml(f)).join('')}</div>`
? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((/** @type {any} */ f) => fileCardHtml(f)).join('')}</div>`
: '';
const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : '';
@@ -265,10 +324,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
wireImageRetry();
}
/**
* @param {any} folder
* @returns {string}
*/
function folderCardHtml(folder) {
return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`;
}
/**
* @param {any} file
* @returns {string}
*/
function fileCardHtml(file) {
const url = fileUrl(file.id);
const kind = mediaKind(file.mime_type);
@@ -286,7 +353,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}
function wireGallery() {
for (const btn of $folder.querySelectorAll('.gallery-view-toggle button')) {
for (const btn of /** @type {NodeListOf<HTMLButtonElement>} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) {
btn.addEventListener('click', () => setViewMode(btn.dataset.view));
}
const backBtn = $folder.querySelector('[data-action="back"]');
@@ -296,14 +363,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
navigate(null, rootDisplayName);
});
}
for (const card of $folder.querySelectorAll('[data-action="open-folder"]')) {
for (const card of /** @type {NodeListOf<HTMLDivElement>} */ ($folder.querySelectorAll('[data-action="open-folder"]'))) {
card.addEventListener('click', (e) => {
if (!(e instanceof MouseEvent)) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
navigate(card.dataset.id, card.dataset.name);
});
}
const mediaCards = Array.from($folder.querySelectorAll('.file-card[data-mediakind]'));
const mediaCards = Array.from(/** @type {NodeListOf<HTMLDivElement>} */ ($folder.querySelectorAll('.file-card[data-mediakind]')));
const items = mediaCards.map((el) => ({
kind: el.dataset.mediakind,
src: el.dataset.src,
@@ -318,6 +386,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
});
}
/**
* @param {string | null | undefined} v
*/
function setViewMode(v) {
const mode = v === 'list' ? 'list' : 'grid';
viewMode = mode;
@@ -327,11 +398,15 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
// ignore
}
document.body.dataset.shareView = mode;
for (const b of $folder.querySelectorAll('.gallery-view-toggle button')) {
for (const b of /** @type {NodeListOf<HTMLButtonElement>} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) {
b.setAttribute('aria-pressed', String(b.dataset.view === mode));
}
}
/**
* @param {string | null} folderId
* @param {string | null | undefined} folderName
*/
function navigate(folderId, folderName) {
currentFolderId = folderId;
currentFolderName = folderName;
@@ -352,7 +427,7 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
function wireLazyVideos() {
const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]');
if (!lazy.length) return;
const start = (v) => {
const start = (/** @type {HTMLVideoElement} */ v) => {
v.addEventListener(
'loadedmetadata',
() => {
@@ -385,20 +460,21 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
if (e.target.dataset.lazySrc && !e.target.src) start(e.target);
obs.unobserve(e.target);
const target = /** @type {HTMLVideoElement} */ (e.target);
if (target.dataset.lazySrc && !target.src) start(target);
obs.unobserve(target);
}
},
{ rootMargin: '300px' }
);
for (const v of lazy) obs.observe(v);
} else {
for (const v of lazy) start(v);
for (const v of lazy) start(/** @type {HTMLVideoElement} */ (v));
}
}
function wireImageRetry() {
for (const img of $folder.querySelectorAll('.file-thumb img')) {
for (const img of /** @type {NodeListOf<HTMLImageElement>} */ ($folder.querySelectorAll('.file-thumb img'))) {
img.addEventListener('error', () => {
if (img.dataset.retried === '1') return;
img.dataset.retried = '1';
@@ -412,7 +488,12 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
}
// ── Lightbox ──────────────────────────────────────────────────
/**
* @typedef {{ root: HTMLElement, title: Element|null, download: HTMLAnchorElement|null, close: HTMLButtonElement|null, stage: Element|null, content: Element|null, prev: HTMLButtonElement|null, next: HTMLButtonElement|null }} LightboxRefs
*/
/** @type {LightboxRefs | null} */
let lb = null;
/** @type {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} */
let lbItems = [];
let lbIndex = -1;
@@ -466,11 +547,18 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
return lb;
}
/**
* @param {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} items
* @param {number} index
*/
function openLightbox(items, index) {
ensureLightbox();
lbItems = items;
showLightboxItem(index);
}
/**
* @param {number} i
*/
function showLightboxItem(i) {
if (i < 0 || i >= lbItems.length) return;
lbIndex = i;
@@ -497,6 +585,9 @@ import { uiFileTypes } from '../../app/uiFileTypes.js';
lb.root.classList.remove('hidden');
lb.root.setAttribute('aria-hidden', 'false');
}
/**
* @param {number} delta
*/
function stepLightbox(delta) {
const next = lbIndex + delta;
if (next >= 0 && next < lbItems.length) showLightboxItem(next);
+76 -76
View File
@@ -6,18 +6,18 @@
import { switchToFilesSection } from '../../app/navigation.js';
import { ui } from '../../app/ui.js';
import { getCsrfHeaders } from '../../core/csrf.js';
import { formatDateShort } from '../../core/formatters.js';
import { formatDateShort, isEmailValid } from '../../core/formatters.js';
import { i18n } from '../../core/i18n.js';
import { fileSharing } from '../../features/sharing/fileSharing.js';
/** @import {Share} from '../../core/types.js' */
/** @import {ShareItem} from '../../core/types.js' */
const TTL = 5 * 60 * 1000; // 5 min
const sharedView = {
// State
/** @type {Array<Share>} */
/** @type {Array<ShareItem>} */
items: [],
_expires: 0,
@@ -25,8 +25,10 @@ const sharedView = {
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
_knownItemsId: new Map(),
/** @type {Array<Share>} */
/** @type {Array<ShareItem>} */
filteredItems: [],
/** @type {ShareItem | null} */
currentItem: null,
/** Auth header helper — tokens are in HttpOnly cookies now */
@@ -238,6 +240,7 @@ const sharedView = {
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
document.querySelectorAll('.shared-custom-select.open').forEach((sel) => {
if (!(e.target instanceof Node)) return;
if (!sel.contains(e.target)) sel.classList.remove('open');
});
});
@@ -249,8 +252,8 @@ const sharedView = {
if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog());
const copyLinkBtn = document.getElementById('sv-copy-link-btn');
if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink());
const enablePw = document.getElementById('sv-enable-password');
const pwField = document.getElementById('sv-share-password');
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
if (enablePw)
enablePw.addEventListener('change', () => {
if (pwField) {
@@ -260,8 +263,8 @@ const sharedView = {
});
const genPwBtn = document.getElementById('sv-generate-password');
if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword());
const enableExp = document.getElementById('sv-enable-expiration');
const expField = document.getElementById('sv-share-expiration');
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
if (enableExp)
enableExp.addEventListener('change', () => {
if (expField) {
@@ -294,6 +297,13 @@ const sharedView = {
},
// Initialize a custom select dropdown
/**
*
* @param {string} wrapperId
* @param {string} toggleId
* @param {string} dropdownId
* @returns
*/
_initCustomSelect(wrapperId, toggleId, dropdownId) {
const wrapper = document.getElementById(wrapperId);
const toggle = document.getElementById(toggleId);
@@ -330,14 +340,14 @@ const sharedView = {
// Filter and sort items
filterAndSortItems() {
const filterTypeActive = document.querySelector('#filter-type-dropdown .shared-select-option.active');
const sortByActive = document.querySelector('#sort-by-dropdown .shared-select-option.active');
const filterTypeActive = /** @type {HTMLDivElement} */ (document.querySelector('#filter-type-dropdown .shared-select-option.active'));
const sortByActive = /** @type {HTMLDivElement} */ (document.querySelector('#sort-by-dropdown .shared-select-option.active'));
const type = filterTypeActive ? filterTypeActive.dataset.value : 'all';
const sort = sortByActive ? sortByActive.dataset.value : 'date';
// Use the main top-bar search input
const searchInput = document.getElementById('search-input');
const searchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input'));
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
this.filteredItems = this.items.filter((item) => {
@@ -396,23 +406,23 @@ const sharedView = {
nameCell.appendChild(nameSpan);
const typeCell = document.createElement('td');
typeCell.textContent = item.item_type === 'file' ? this.translate('shared_typeFile', 'File') : this.translate('shared_typeFolder', 'Folder');
typeCell.textContent = item.item_type === 'file' ? i18n.t('shared_typeFile', 'File') : i18n.t('shared_typeFolder', 'Folder');
const dateCell = document.createElement('td');
dateCell.textContent = this.formatDate(item.created_at);
dateCell.textContent = formatDateShort(item.created_at);
const expCell = document.createElement('td');
expCell.textContent = item.expires_at ? this.formatDate(item.expires_at) : this.translate('shared_noExpiration', 'No expiration');
expCell.textContent = item.expires_at ? formatDateShort(item.expires_at) : i18n.t('shared_noExpiration', 'No expiration');
const permCell = document.createElement('td');
const perms = [];
if (item.permissions?.read) perms.push(this.translate('share_permissionRead', 'Read'));
if (item.permissions?.write) perms.push(this.translate('share_permissionWrite', 'Write'));
if (item.permissions?.reshare) perms.push(this.translate('share_permissionReshare', 'Reshare'));
if (item.permissions?.read) perms.push(i18n.t('share_permissionRead', 'Read'));
if (item.permissions?.write) perms.push(i18n.t('share_permissionWrite', 'Write'));
if (item.permissions?.reshare) perms.push(i18n.t('share_permissionReshare', 'Reshare'));
permCell.textContent = perms.join(', ') || 'Read';
const pwCell = document.createElement('td');
pwCell.textContent = item.has_password ? this.translate('shared_hasPassword', 'Yes') : this.translate('shared_noPassword', 'No');
pwCell.textContent = item.has_password ? i18n.t('shared_hasPassword', 'Yes') : i18n.t('shared_noPassword', 'No');
const actionsCell = document.createElement('td');
actionsCell.className = 'shared-item-actions';
@@ -420,30 +430,30 @@ const sharedView = {
const editBtn = document.createElement('button');
editBtn.className = 'action-btn edit-btn';
editBtn.innerHTML = '<span class="action-icon">✏️</span>';
editBtn.title = this.translate('shared_editShare', 'Edit Share');
editBtn.title = i18n.t('shared_editShare', 'Edit Share');
editBtn.addEventListener('click', () => this.openShareDialog(item));
const notifyBtn = document.createElement('button');
notifyBtn.className = 'action-btn notify-btn';
notifyBtn.innerHTML = '<span class="action-icon">📧</span>';
notifyBtn.title = this.translate('shared_notifyShare', 'Notify Someone');
notifyBtn.title = i18n.t('shared_notifyShare', 'Notify Someone');
notifyBtn.addEventListener('click', () => this.openNotificationDialog(item));
const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn copy-btn';
copyBtn.innerHTML = '<span class="action-icon">📋</span>';
copyBtn.title = this.translate('shared_copyLink', 'Copy Link');
copyBtn.title = i18n.t('shared_copyLink', 'Copy Link');
copyBtn.addEventListener('click', () => {
navigator.clipboard
.writeText(item.url)
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!')))
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
.then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
});
const rmBtn = document.createElement('button');
rmBtn.className = 'action-btn remove-btn';
rmBtn.innerHTML = '<span class="action-icon">🗑️</span>';
rmBtn.title = this.translate('shared_removeShare', 'Remove Share');
rmBtn.title = i18n.t('shared_removeShare', 'Remove Share');
rmBtn.addEventListener('click', () => {
this.currentItem = item;
this.removeSharedItem();
@@ -456,6 +466,11 @@ const sharedView = {
},
// Open share dialog
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openShareDialog(item) {
this.currentItem = item;
const shareDialog = document.getElementById('shared-view-edit-dialog');
@@ -463,14 +478,14 @@ const sharedView = {
const iconEl = document.getElementById('sv-dialog-icon');
const nameEl = document.getElementById('sv-dialog-name');
const urlEl = document.getElementById('sv-share-link-url');
const enablePw = document.getElementById('sv-enable-password');
const pwField = document.getElementById('sv-share-password');
const enableExp = document.getElementById('sv-enable-expiration');
const expField = document.getElementById('sv-share-expiration');
const permRead = document.getElementById('sv-permission-read');
const permWrite = document.getElementById('sv-permission-write');
const permReshare = document.getElementById('sv-permission-reshare');
const urlEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
if (!shareDialog) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
@@ -505,14 +520,19 @@ const sharedView = {
this.currentItem = null;
},
/**
*
* @param {ShareItem} item
* @returns {void}
*/
openNotificationDialog(item) {
this.currentItem = item;
const dn = item.item_name || item.item_id || 'Unknown';
const d = document.getElementById('sv-notification-dialog');
const iconEl = document.getElementById('sv-notify-dialog-icon');
const nameEl = document.getElementById('sv-notify-dialog-name');
const emailEl = document.getElementById('sv-notification-email');
const msgEl = document.getElementById('sv-notification-message');
const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
if (!d) return;
if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁';
@@ -529,18 +549,18 @@ const sharedView = {
},
copyShareLink() {
const el = document.getElementById('sv-share-link-url');
const el = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-link-url'));
if (!el) return;
navigator.clipboard
.writeText(el.value)
.then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!')))
.catch(() => this.showNotification(this.translate('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
.then(() => ui.showNotification(i18n.t('shared_linkCopied', 'Link copied!'), 'success'))
.catch(() => ui.showNotification(i18n.t('shared_linkCopyFailed', 'Failed to copy link'), 'error'));
},
// Generate secure password with crypto API
generatePassword() {
const pwField = document.getElementById('sv-share-password');
const enablePw = document.getElementById('sv-enable-password');
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
if (!pwField || !enablePw) return;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
@@ -559,13 +579,13 @@ const sharedView = {
async updateSharedItem() {
if (!this.currentItem) return;
const permRead = document.getElementById('sv-permission-read');
const permWrite = document.getElementById('sv-permission-write');
const permReshare = document.getElementById('sv-permission-reshare');
const enablePw = document.getElementById('sv-enable-password');
const pwField = document.getElementById('sv-share-password');
const enableExp = document.getElementById('sv-enable-expiration');
const expField = document.getElementById('sv-share-expiration');
const permRead = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-read'));
const permWrite = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-write'));
const permReshare = /** @type {HTMLInputElement} */ (document.getElementById('sv-permission-reshare'));
const enablePw = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-password'));
const pwField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-password'));
const enableExp = /** @type {HTMLInputElement} */ (document.getElementById('sv-enable-expiration'));
const expField = /** @type {HTMLInputElement} */ (document.getElementById('sv-share-expiration'));
const body = {
permissions: {
@@ -588,10 +608,10 @@ const sharedView = {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Server error ${res.status}`);
}
this.showNotification(this.translate('shared_itemUpdated', 'Share settings updated'));
ui.showNotification(i18n.t('shared_itemUpdated', 'Share settings updated'), 'success');
} catch (err) {
console.error('Error updating share:', err);
this.showNotification(err.message || 'Error updating share', 'error');
ui.showNotification(/** @type {Error} */ (err).message || 'Error updating share', 'error');
}
// update UI
ui.setSharedVisualState(this.currentItem.item_id, this.currentItem.item_type, true);
@@ -611,10 +631,10 @@ const sharedView = {
headers: this._headers()
});
if (!res.ok && res.status !== 204) throw new Error(`Server error ${res.status}`);
this.showNotification(this.translate('shared_itemRemoved', 'Share removed'));
ui.showNotification(i18n.t('shared_itemRemoved', 'Share removed'), 'success');
} catch (err) {
console.error('Error removing share:', err);
this.showNotification('Error removing share', 'error');
ui.showNotification('Error removing share', 'error');
}
this.closeShareDialog();
@@ -627,13 +647,13 @@ const sharedView = {
// Send notification (stub)
sendNotification() {
if (!this.currentItem) return;
const emailEl = document.getElementById('sv-notification-email');
const msgEl = document.getElementById('sv-notification-message');
const emailEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-email'));
const msgEl = /** @type {HTMLInputElement} */ (document.getElementById('sv-notification-message'));
const email = emailEl ? emailEl.value.trim() : '';
const message = msgEl ? msgEl.value.trim() : '';
if (!email || !this.validateEmail(email)) {
this.showNotification(this.translate('shared_invalidEmail', 'Please enter a valid email address'), 'error');
if (!email || !isEmailValid(email)) {
ui.showNotification(i18n.t('shared_invalidEmail', 'Please enter a valid email address'), 'error');
return;
}
@@ -642,30 +662,10 @@ const sharedView = {
.sendShareNotification(this.currentItem.url, email, message)
.then(() => {
this.closeNotificationDialog();
this.showNotification(this.translate('shared_notificationSent', 'Notification sent'));
ui.showNotification(i18n.t('shared_notificationSent', 'Notification sent'), 'success');
})
.catch(() => this.showNotification(this.translate('shared_notificationFailed', 'Failed to send notification'), 'error'));
.catch(() => ui.showNotification(i18n.t('shared_notificationFailed', 'Failed to send notification'), 'error'));
}
},
showNotification(message, type = 'success') {
if (ui?.showNotification) {
ui.showNotification(message, type);
} else {
alert(message);
}
},
validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
},
formatDate(value) {
return formatDateShort(value);
},
translate(key, defaultText) {
return i18n.t(key, defaultText);
}
};
+4
View File
@@ -17,3 +17,7 @@ OXICLOUD_WOPI_ENABLED=false
OXICLOUD_OIDC_ENABLED=false
RUST_LOG=warn
#RUST_LOG=debug
# grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
OXICLOUD_RATE_LIMIT_LOGIN_MAX=120
+6
View File
@@ -0,0 +1,6 @@
import { execSync } from 'child_process';
import * as path from 'path';
export default async function globalTeardown() {
execSync(`bash ${path.join(__dirname, '../common/stop-db.sh')}`, { stdio: 'inherit' });
}
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"pretest": "bash ../common/spawn-db.sh",
"test": "npx playwright test",
"test": "npx playwright test --reporter=line,github --trace=on-first-retry scenarios/01-home-and-login.spec.ts scenarios/02-folder-management.spec.ts",
"posttest": "bash ../common/stop-db.sh"
},
"keywords": [],
+9 -10
View File
@@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const startScript = path.join(__dirname, 'start-server.sh');
/** Parse a KEY=VALUE env file, skipping blank lines and comments. */
function loadEnv(filePath: string): Record<string, string> {
const env: Record<string, string> = {};
@@ -23,13 +25,14 @@ const workspace=process.env.GITHUB_WORKSPACE ?? path.join(__dirname, '../..');
export default defineConfig({
testDir: './scenarios',
fullyParallel: true,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: 1,
reporter: process.env.CI ? [['github'], ['html']] : 'html',
globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
use: {
baseURL: 'http://localhost:8087',
@@ -45,22 +48,18 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: process.env.BUILD_TARGET
? `${workspace}/target/${process.env.BUILD_TARGET}/oxicloud`
: 'cargo run',
? `bash "${startScript}" "${workspace}/target/${process.env.BUILD_TARGET}/oxicloud"`
: `bash "${startScript}" cargo run`,
url: 'http://localhost:8087',
timeout: 600_000,
reuseExistingServer: false,
cwd: '../..',
stdout: 'pipe',
stderr: 'pipe',
stdout: 'inherit',
stderr: 'inherit',
env: {
...commonEnv,
OXICLOUD_SERVER_PORT: '8087',
@@ -43,7 +43,7 @@ test.describe('authenticated as admin', () => {
await loginAsAdmin(page);
});
test('home page shows files list', async ({ page }) => {
test('home page shows empty files list', async ({ page }) => {
await expect(page.locator('.files-container')).toBeVisible();
await expect(page.locator('#user-menu-wrapper')).toBeVisible();
await expect(page).toHaveScreenshot('home-files.png', {
@@ -52,6 +52,8 @@ test.describe('authenticated as admin', () => {
animations: 'disabled',
mask: [page.locator('.storage-bar'), page.locator('.storage-info') ]
});
await expect(page.locator('#files-container-error')).toBeVisible();
await expect(page.locator('#files-container-error')).toContainText("No files in this folder");
});
test('theme can be changed to dark', async ({ page }) => {
@@ -0,0 +1,232 @@
import * as path from 'path';
import * as fs from 'fs/promises';
import { test, expect, Page } from '@playwright/test';
import { loginAsAdmin } from './helpers';
const FIXTURES = path.join(__dirname, '../../fixtures');
/**
* Opens the new-folder modal, fills the name, and clicks Confirm.
* Does NOT wait for the modal to close — the caller decides what to assert next.
*/
async function submitNewFolder(page: Page, name: string) {
await page.locator('#new-folder-btn').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill(name);
await page.locator('#modal-confirm-btn').click();
}
function mimeFromPath(fp: string): string {
const map: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.mp4': 'video/mp4',
'.pdf': 'application/pdf',
};
return map[path.extname(fp).toLowerCase()] ?? 'application/octet-stream';
}
/**
* Simulates a file drag-and-drop onto the OxiCloud dropzone overlay.
*
* How it works:
* 1. File buffers are read in Node and transferred into the page as File
* objects inside a DataTransfer — no real OS drag needed.
* 2. A document-level 'dragover' is dispatched so the app reveals the
* dropzone (same code path as a real drag from the OS).
* 3. A 'drop' is dispatched on #dropzone. The handler falls back to
* dataTransfer.files when webkitGetAsEntry() returns null (which it
* does for programmatic File objects), hitting the same upload path.
*/
async function dragFilesToDropzone(page: Page, filePaths: string[]) {
const fileData = await Promise.all(
filePaths.map(async (fp) => ({
name: path.basename(fp),
buffer: Array.from(await fs.readFile(fp)),
type: mimeFromPath(fp),
}))
);
const dataTransfer = await page.evaluateHandle((files) => {
const dt = new DataTransfer();
for (const { name, buffer, type } of files) {
dt.items.add(new File([new Uint8Array(buffer)], name, { type }));
}
return dt;
}, fileData);
// Reveal the dropzone overlay (same logic as a real OS drag).
await dataTransfer.evaluate((dt) => {
document.dispatchEvent(new DragEvent('dragover', {
bubbles: true, cancelable: true, dataTransfer: dt,
}));
});
await expect(page.locator('#dropzone')).toBeVisible();
await page.locator('#dropzone').dispatchEvent('drop', { dataTransfer });
}
test.describe('Folder management', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('folder creation', async ({ page }) => {
const name = `Test folder ${Date.now()}`;
await submitNewFolder(page, name);
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${name}"]`)).toBeVisible();
// Screenshot: mask dynamic text so layout is what's tested, not the name.
await expect(page).toHaveScreenshot('folder-created.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
test('folder reject if already exists', async ({ page }) => {
const name = `Test folder ${Date.now()}`;
// Prerequisite: create the folder once successfully.
await submitNewFolder(page, name);
await expect(page.locator('#input-modal')).toBeHidden();
// Second creation with the same name must keep the modal open with an error.
await submitNewFolder(page, name);
await expect(page.locator('#modal-error')).toBeVisible();
await expect(page.locator('#modal-error')).toContainText('already exists');
// Cancel leaves the list unchanged.
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
});
test('folder creation rejected if bad name', async ({ page }) => {
await page.locator('#new-folder-btn').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill('/');
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-error')).toBeVisible();
await expect(page.locator('#modal-error')).toContainText('Invalid folder name');
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
});
test('folder rename', async ({ page }) => {
const ts = Date.now();
const original = `Test folder ${ts}`;
const renamed = `Renamed folder ${ts}`;
// Prerequisite: create the folder to rename.
await submitNewFolder(page, original);
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).toBeVisible();
// Open context menu and trigger rename.
await page.locator(`.file-item[data-folder-name="${original}"] .file-actions`).click();
await expect(page.locator('#folder-context-menu')).toBeVisible();
await page.locator('#rename-folder-option').click();
await expect(page.locator('#input-modal')).toBeVisible();
await page.locator('#modal-input').fill(renamed);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#input-modal')).toBeHidden();
await expect(page.locator(`.file-item[data-folder-name="${renamed}"]`)).toBeVisible();
await expect(page.locator(`.file-item[data-folder-name="${original}"]`)).not.toBeVisible();
await expect(page).toHaveScreenshot('folder-renamed.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
// ── File upload ────────────────────────────────────────────────────────────
// These two tests are intentionally sequential: test 1 uploads the files,
// test 2 reads the state left by test 1. Both run inside the same describe
// block so Playwright executes them in order within a single worker.
test('upload of multiple files', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('#upload-btn').click();
await expect(page.locator('#upload-dropdown-menu')).toBeVisible();
await page.locator('#upload-files-btn').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
path.join(FIXTURES, 'oxicloud-logo.jpg'),
path.join(FIXTURES, 'free_video_over_1MB.mp4'),
]);
// Each file appears in the list as soon as its individual upload completes.
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible({ timeout: 15_000 });
});
test('uploaded files rendered with thumbnails', async ({ page }) => {
// The beforeEach login re-loads the home folder; files uploaded in the
// previous test are already in the DB and visible immediately.
await expect(page.locator('.file-item[data-file-name="oxicloud-logo.jpg"]')).toBeVisible();
await expect(page.locator('.file-item[data-file-name="free_video_over_1MB.mp4"]')).toBeVisible();
// Image thumbnail is generated server-side: wait for the <img> to be
// visible (the error handler hides it while the server is still processing,
// then un-hides it once the src resolves successfully).
await expect(
page.locator('.file-item[data-file-name="oxicloud-logo.jpg"] .file-thumb')
).toBeVisible({ timeout: 10_000 });
// Video thumbnail is generated client-side from a canvas frame extraction.
// Give the browser 1 s to finish before taking the screenshot.
await page.waitForTimeout(1_000);
await expect(page).toHaveScreenshot('files-with-thumbnails.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'), // upload timestamps differ every run
],
});
});
test('drag and drop images onto dropzone', async ({ page }) => {
await dragFilesToDropzone(page, [
path.join(FIXTURES, 'blue-image.png'),
path.join(FIXTURES, 'green-image.png'),
path.join(FIXTURES, 'red-image.png'),
]);
// Each card appears as its upload completes.
await expect(page.locator('.file-item[data-file-name="blue-image.png"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="green-image.png"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('.file-item[data-file-name="red-image.png"]')).toBeVisible({ timeout: 15_000 });
// PNGs get server-side thumbnails — wait for at least the first to resolve.
await expect(
page.locator('.file-item[data-file-name="blue-image.png"] .file-thumb')
).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveScreenshot('dropped-files.png', {
animations: 'disabled',
mask: [
page.locator('.storage-bar'),
page.locator('.storage-info'),
page.locator('.date-cell'),
],
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Boot a clean test DB then start OxiCloud (passed as arguments).
# Used by playwright.config.ts as the webServer command so that both
# `npm test` and `npx playwright test` always start from an empty database.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/e2e/storage"
# ensure storage is empty before starting
echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup"
rm -rf "$OXICLOUD_STORAGE_PATH"
mkdir -p "$OXICLOUD_STORAGE_PATH"
# Spawn database
bash "$REPO_ROOT/tests/common/spawn-db.sh"
# Replace the shell with the server process so Playwright's PID tracking works.
exec "$@"
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
# **Lorem ipsum**
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Binary file not shown.
Binary file not shown.
+327
View File
@@ -0,0 +1,327 @@
{\rtf1\ansi\ansicpg1252\uc0\stshfdbch0\stshfloch0\stshfhich0\stshfbi0\deff0\adeff0{\fonttbl{\f0\froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\f1\froman\fcharset2\fprq2{\*\panose 05050102010706020507}Symbol;}{\f2\fswiss\fcharset0\fprq2{\*\panose 020b0604020202020204}Arial;}}{\colortbl;\red0\green0\blue0;\red100\green107\blue107;
\red67\green67\blue67;\red102\green102\blue102;}{\stylesheet{\s0\snext0\sqformat\spriority0\fi0\sb0\sa0\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 Normal;}{\s1\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb400\sa120\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs40\ltrch\b0\i0\fs40\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 heading 1;}{\s2\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb360\sa120\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs32\ltrch\b0\i0\fs32\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 heading 2;}{\s3\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb320\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs28\ltrch\b0\i0\fs28\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf3 heading 3;}{\s4\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb280\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs24\ltrch\b0\i0\fs24\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 heading 4;}{\s5\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb240\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 heading 5;}{\s6\sbasedon0\snext0\styrsid15694742
\sqformat\spriority0\keep\keepn\fi0\sb240\sa80\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai\af2\afs22\ltrch\b0\i\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 heading 6;}{\*\cs10\additive\ssemihidden\spriority0 Default Paragraph Font;
}{\*\ts11\tsrowd\snext11\ssemihidden\spriority0\aspalpha\aspnum\adjustright\ltrpar\li0\lin0\ri0\rin0\ql\faauto\tsvertalt\tsbrdrl\tsbrdrr\tsbrdrt\tsbrdrb\tsbrdrdgr\tsbrdrdgl\tsbrdrh\tsbrdrv\trpaddl108\trpaddfl3\trwWidthB0\trftsWidthB3\trpaddt0\trpaddft3\trpaddb0
\trpaddfb3\trpaddr108\trpaddfr3 Normal Table;}{\s15\sbasedon0\snext15\styrsid15694742\sqformat\spriority0\keep\keepn\fi0\sb0\sa60\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs52\ltrch\b0\i0\fs52\loch\af2
\dbch\af2\hich\f2\strike0\ulnone\cf1 Title;}{\s16\sbasedon0\snext16\styrsid15694742\sqformat\spriority0\keep\keepn\fi0\sb0\sa320\aspalpha\aspnum\adjustright\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl240\slmult1\rtlch\ab0\ai0\af2\afs30\ltrch\b0\i0\fs30
\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf4 Subtitle;}}{\*\rsidtbl\rsid10976062\rsid13249109}{\*\generator Aspose.Words for Java 23.4.0;}{\info\version1\edmins0\nofpages1\nofwords0\nofchars0\nofcharsws0}\paperw11909\paperh16834\margl1440\margr1440\margt1440\margb1440\gutter0
{\mmathPr\mbrkBin0\mbrkBinSub0\mdefJc1\mdispDef1\minterSp0\mintLim0\mintraSp0\mlMargin0\mmathFont0\mnaryLim1\mpostSp0\mpreSp0\mrMargin0\msmallFrac0\mwrapIndent1440\mwrapRight0}\deflang1033\deflangfe2052\adeflang1025\jexpand\showxmlerrors1\validatexml1{
\*\wgrffmtfilter 013f}\viewkind1\viewscale100\fet0\ftnbj\aenddoc\ftnrstcont\aftnrstcont\ftnnar\aftnnrlc\widowctrl\nospaceforul\nolnhtadjtbl\alntblind\lyttblrtgr\dntblnsbdb\noxlattoyen\wrppunct\nobrkwrptbl\expshrtn\snaptogridincell\asianbrkrule\htmautsp\noultrlspc
\useltbaln\splytwnine\ftnlytwnine\lytcalctblwd\allowfieldendsel\lnbrkrule\nouicompat\nofeaturethrottle1\utinl\formshade\nojkernpunct\dghspace180\dgvspace180\dghorigin1800\dgvorigin1440\dghshow1\dgvshow1\dgmargin\pgbrdrhead\pgbrdrfoot\rsidroot10976062\sectd\sectlinegrid360\pgwsxn11909\pghsxn16834\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\guttersxn0\headery720\footery720\colsx720\ltrsect\pard\plain\itap0\s15\keep\keepn\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr
\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs52\ltrch\b0\i0\fs52\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\*\bkmkstart h.mjnd912kz2gu}{\*\bkmkend h.mjnd912kz2gu}{\rtlch\ab\ai0\af2\alang1025\afs52\ltrch\b\i0\fs52\lang1033\langnp1033\langfe1033\langfenp1033
\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 Lorem}{\rtlch\ab\ai0\af2\alang1025\afs52\ltrch\b\i0\fs52\lang1033\langnp1033\langfe1033\langfenp1033\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 }{\rtlch\ab\ai0\af2\alang1025\afs52\ltrch\b\i0\fs52\lang1033\langnp1033\langfe1033\langfenp1033
\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1 ipsum}{\rtlch\ab\ai0\af2\afs52\ltrch\b\i0\fs52\loch\af2\dbch\af2\hich\f2\insrsid10976062\strike0\ulnone\cf1\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw
\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360
\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0
\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0
\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 amet}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa360\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\qj\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Lorem}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ipsum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 dolor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 amet}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 consectetur}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 adipiscing}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 elit}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Quisque}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 faucibus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 ex}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sapien}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vitae}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pellentesque}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sem}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 placerat}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 In}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 id}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 cursus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 mi}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 pretium}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tellus}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 duis}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 convallis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Tempus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 leo}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 eu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 aenean}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sed}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 diam}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 urna}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 tempor}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Pulvinar}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 vivamus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 fringilla}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 lacus}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nec}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 metus}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 bibendum}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 egestas}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 . }
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Iaculis}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 massa}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nisl}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 malesuada}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 lacinia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 integer}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nunc}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 posuere}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ut}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 hendrerit}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 semper}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 vel}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 class}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 aptent}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 taciti}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 sociosqu}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 . }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 Ad}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 litora}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 torquent}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 per}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 conubia}
{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 nostra}{
\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 inceptos}{\rtlch\ab0\ai0\af0\alang1025\afs42
\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 }{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0\ulnone\cf2 himenaeos}{\rtlch\ab0\ai0\af0\alang1025\afs42\ltrch\b0\i0\fs42\lang1033\langnp1033\langfe1033\langfenp1033\f0\strike0
\ulnone\cf2 .}{\rtlch\ab0\ai0\af0\afs42\ltrch\b0\i0\fs42\f0\insrsid10976062\strike0\ulnone\cf2\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa0\aspalpha\aspnum\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276
\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\insrsid10976062\strike0\ulnone\cf1\par}\pard\plain\itap0\s0\ilvl0\fi0\sb0\sa0\aspalpha\aspnum
\adjustright\brdrt\brdrl\brdrb\brdrr\brdrbtw\brdrbar\widctlpar\ltrpar\li0\lin0\ri0\rin0\ql\faauto\sl276\slmult1\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\strike0\ulnone\cf1{\rtlch\ab0\ai0\af2\afs22\ltrch\b0\i0\fs22\loch\af2\dbch\af2\hich\f2\insrsid10976062\strike0
\ulnone\cf1\par}{
\*\latentstyles\lsdstimax267\lsdlockeddef0\lsdsemihiddendef0\lsdunhideuseddef0\lsdqformatdef0\lsdprioritydef0{\lsdlockedexcept\lsdqformat1 Normal;\lsdqformat1 heading 1;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 2;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 3;
\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 4;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 5;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 6;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 7;\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 8;
\lsdsemihidden1\lsdunhideused1\lsdqformat1 heading 9;\lsdsemihidden1\lsdunhideused1\lsdqformat1 caption;\lsdqformat1 Title;\lsdqformat1 Subtitle;\lsdqformat1 Strong;\lsdqformat1 Emphasis;\lsdsemihidden1\lsdpriority99 Placeholder Text;\lsdqformat1\lsdpriority1 No Spacing;
\lsdpriority60 Light Shading;\lsdpriority61 Light List;\lsdpriority62 Light Grid;\lsdpriority63 Medium Shading 1;\lsdpriority64 Medium Shading 2;\lsdpriority65 Medium List 1;\lsdpriority66 Medium List 2;\lsdpriority67 Medium Grid 1;\lsdpriority68 Medium Grid 2;
\lsdpriority69 Medium Grid 3;\lsdpriority70 Dark List;\lsdpriority71 Colorful Shading;\lsdpriority72 Colorful List;\lsdpriority73 Colorful Grid;\lsdpriority60 Light Shading Accent 1;\lsdpriority61 Light List Accent 1;\lsdpriority62 Light Grid Accent 1;\lsdpriority63 Medium Shading 1 Accent 1;
\lsdpriority64 Medium Shading 2 Accent 1;\lsdpriority65 Medium List 1 Accent 1;\lsdsemihidden1\lsdpriority99 Revision;\lsdqformat1\lsdpriority34 List Paragraph;\lsdqformat1\lsdpriority29 Quote;\lsdqformat1\lsdpriority30 Intense Quote;\lsdpriority66 Medium List 2 Accent 1;
\lsdpriority67 Medium Grid 1 Accent 1;\lsdpriority68 Medium Grid 2 Accent 1;\lsdpriority69 Medium Grid 3 Accent 1;\lsdpriority70 Dark List Accent 1;\lsdpriority71 Colorful Shading Accent 1;\lsdpriority72 Colorful List Accent 1;\lsdpriority73 Colorful Grid Accent 1;
\lsdpriority60 Light Shading Accent 2;\lsdpriority61 Light List Accent 2;\lsdpriority62 Light Grid Accent 2;\lsdpriority63 Medium Shading 1 Accent 2;\lsdpriority64 Medium Shading 2 Accent 2;\lsdpriority65 Medium List 1 Accent 2;\lsdpriority66 Medium List 2 Accent 2;
\lsdpriority67 Medium Grid 1 Accent 2;\lsdpriority68 Medium Grid 2 Accent 2;\lsdpriority69 Medium Grid 3 Accent 2;\lsdpriority70 Dark List Accent 2;\lsdpriority71 Colorful Shading Accent 2;\lsdpriority72 Colorful List Accent 2;\lsdpriority73 Colorful Grid Accent 2;
\lsdpriority60 Light Shading Accent 3;\lsdpriority61 Light List Accent 3;\lsdpriority62 Light Grid Accent 3;\lsdpriority63 Medium Shading 1 Accent 3;\lsdpriority64 Medium Shading 2 Accent 3;\lsdpriority65 Medium List 1 Accent 3;\lsdpriority66 Medium List 2 Accent 3;
\lsdpriority67 Medium Grid 1 Accent 3;\lsdpriority68 Medium Grid 2 Accent 3;\lsdpriority69 Medium Grid 3 Accent 3;\lsdpriority70 Dark List Accent 3;\lsdpriority71 Colorful Shading Accent 3;\lsdpriority72 Colorful List Accent 3;\lsdpriority73 Colorful Grid Accent 3;
\lsdpriority60 Light Shading Accent 4;\lsdpriority61 Light List Accent 4;\lsdpriority62 Light Grid Accent 4;\lsdpriority63 Medium Shading 1 Accent 4;\lsdpriority64 Medium Shading 2 Accent 4;\lsdpriority65 Medium List 1 Accent 4;\lsdpriority66 Medium List 2 Accent 4;
\lsdpriority67 Medium Grid 1 Accent 4;\lsdpriority68 Medium Grid 2 Accent 4;\lsdpriority69 Medium Grid 3 Accent 4;\lsdpriority70 Dark List Accent 4;\lsdpriority71 Colorful Shading Accent 4;\lsdpriority72 Colorful List Accent 4;\lsdpriority73 Colorful Grid Accent 4;
\lsdpriority60 Light Shading Accent 5;\lsdpriority61 Light List Accent 5;\lsdpriority62 Light Grid Accent 5;\lsdpriority63 Medium Shading 1 Accent 5;\lsdpriority64 Medium Shading 2 Accent 5;\lsdpriority65 Medium List 1 Accent 5;\lsdpriority66 Medium List 2 Accent 5;
\lsdpriority67 Medium Grid 1 Accent 5;\lsdpriority68 Medium Grid 2 Accent 5;\lsdpriority69 Medium Grid 3 Accent 5;\lsdpriority70 Dark List Accent 5;\lsdpriority71 Colorful Shading Accent 5;\lsdpriority72 Colorful List Accent 5;\lsdpriority73 Colorful Grid Accent 5;
\lsdpriority60 Light Shading Accent 6;\lsdpriority61 Light List Accent 6;\lsdpriority62 Light Grid Accent 6;\lsdpriority63 Medium Shading 1 Accent 6;\lsdpriority64 Medium Shading 2 Accent 6;\lsdpriority65 Medium List 1 Accent 6;\lsdpriority66 Medium List 2 Accent 6;
\lsdpriority67 Medium Grid 1 Accent 6;\lsdpriority68 Medium Grid 2 Accent 6;\lsdpriority69 Medium Grid 3 Accent 6;\lsdpriority70 Dark List Accent 6;\lsdpriority71 Colorful Shading Accent 6;\lsdpriority72 Colorful List Accent 6;\lsdpriority73 Colorful Grid Accent 6;
\lsdqformat1\lsdpriority19 Subtle Emphasis;\lsdqformat1\lsdpriority21 Intense Emphasis;\lsdqformat1\lsdpriority31 Subtle Reference;\lsdqformat1\lsdpriority32 Intense Reference;\lsdqformat1\lsdpriority33 Book Title;\lsdsemihidden1\lsdunhideused1\lsdpriority37 Bibliography;
\lsdsemihidden1\lsdunhideused1\lsdqformat1\lsdpriority39 TOC Heading;}}}
Binary file not shown.