| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import _ from 'lodash' |
| 4 | import { proxy, useSnapshot } from 'valtio' |
| 5 | import { subscribeKey } from 'valtio/utils' |
| 6 | import { FRONTEND_OPTIONS, getHFS, hfsEvent, hIcon, pathEncode, typedKeys } from './misc' |
| 7 | import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list' |
| 8 | |
| 9 | export const state = proxy<typeof FRONTEND_OPTIONS & { |
| 10 | stopSearch?: ()=>void, |
| 11 | searchManuallyInterrupted?: boolean, |
| 12 | iconsReady: boolean, |
| 13 | username: string, |
| 14 | accountExp?: string, |
| 15 | list: DirList, |
| 16 | filteredList?: DirList, |
| 17 | clip: DirList |
| 18 | loading: boolean, |
| 19 | error?: string, |
| 20 | listReloader: number, |
| 21 | patternFilter: string, |
| 22 | showFilter: boolean, |
| 23 | selected: { [uri:string]: true }, // by using an object instead of an array, Entry components are not rendered when others get selected |
| 24 | remoteSearch: { search?: string, searchComment?: string, wild?: string } | undefined, |
| 25 | isAdmin?: boolean, |
| 26 | adminUrl?: string, |
| 27 | loginRequired?: boolean, // force user to login before proceeding |
| 28 | messageOnly?: string, // no gui, just show this message |
| 29 | props?: { |
| 30 | can_upload?: boolean |
| 31 | accept?: string |
| 32 | can_delete?: boolean |
| 33 | can_delete_children?: boolean |
| 34 | can_archive?: boolean |
| 35 | can_comment?: boolean |
| 36 | can_overwrite?: boolean |
| 37 | comment?: string |
| 38 | } |
| 39 | canChangePassword: boolean |
| 40 | uri: string |
| 41 | uploadOnExisting: 'skip' | 'overwrite' | 'rename' |
| 42 | expandedUsername?: string[] |
| 43 | }>({ |
| 44 | uploadOnExisting: getHFS().dontOverwriteUploading ? 'rename' : 'skip', |
| 45 | uri: '', |
| 46 | canChangePassword: false, |
| 47 | props: {}, |
| 48 | ..._.mapValues(FRONTEND_OPTIONS, (v,k) => getHFS()[k] ?? v), |
| 49 | iconsReady: false, |
| 50 | username: '', |
| 51 | list: [], |
| 52 | clip: [], |
| 53 | loading: false, |
| 54 | listReloader: 0, |
| 55 | patternFilter: '', |
| 56 | showFilter: false, |
| 57 | selected: {}, |
| 58 | remoteSearch: undefined, |
| 59 | }) |
| 60 | |
| 61 | export function useSnapState() { |
| 62 | return useSnapshot(state) |
| 63 | } |
| 64 | |
| 65 | const SETTINGS_KEY = 'hfs_settings' |
| 66 | type StateKey = keyof typeof state |
| 67 | const SETTINGS_WITHOUT_GUI: StateKey[] = ['file_menu_on_link', 'page_size', 'title_with_path'] |
| 68 | const SETTINGS_TO_STORE: StateKey[] = _.difference(typedKeys(FRONTEND_OPTIONS), SETTINGS_WITHOUT_GUI) |
| 69 | .concat(['uploadOnExisting']) // not adding this to FRONTEND_OPTIONS, as its possible values vary with the user permissions, but still makes sense to save on a single browser, supposedly for a single user |
| 70 | |
| 71 | loadSettings() |
| 72 | for (const k of SETTINGS_TO_STORE) |
| 73 | subscribeKey(state, k, storeSettings) |
| 74 | |
| 75 | function loadSettings() { |
| 76 | const json = localStorage.getItem(SETTINGS_KEY) |
| 77 | if (!json) return |
| 78 | let read |
| 79 | try { read = JSON.parse(json) } |
| 80 | catch { |
| 81 | console.error('invalid settings stored', json) |
| 82 | return |
| 83 | } |
| 84 | for (const k of SETTINGS_TO_STORE) { |
| 85 | const v = read[k] |
| 86 | if (v !== undefined) // @ts-ignore |
| 87 | state[k] = v |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | function storeSettings() { |
| 92 | localStorage.setItem(SETTINGS_KEY, JSON.stringify(_.pick(state, SETTINGS_TO_STORE))) |
| 93 | } |
| 94 | |
| 95 | export class DirEntry implements ServerDirEntry { |
| 96 | static FORBIDDEN = 'FORBIDDEN' |
| 97 | public readonly n: string |
| 98 | public readonly s?: number |
| 99 | public readonly m?: Date |
| 100 | public readonly c?: Date |
| 101 | public readonly p?: string |
| 102 | public readonly icon?: string | true |
| 103 | public readonly web?: true |
| 104 | public readonly url?: string |
| 105 | public readonly target?: string |
| 106 | public readonly order?: number |
| 107 | public comment?: string |
| 108 | // we memoize these value for speed |
| 109 | public readonly name: string |
| 110 | public readonly uri: string |
| 111 | public readonly ext: string = '' |
| 112 | public readonly isFolder: boolean |
| 113 | public readonly cantOpen?: true | typeof DirEntry.FORBIDDEN |
| 114 | public readonly key?: string |
| 115 | |
| 116 | constructor(n: string, rest?: any) { |
| 117 | this.isFolder = n.endsWith('/') |
| 118 | if (this.isFolder) |
| 119 | n = n.slice(0, -1) |
| 120 | Object.assign(this, rest) // we actually allow any custom property to be memorized |
| 121 | this.n = n // must do it after 'rest' to avoid overwriting |
| 122 | this.uri = (!n || n[0] === '/' ? '' : location.pathname) + pathEncode(n) + (this.isFolder ? '/' : '') |
| 123 | if (!this.isFolder) { |
| 124 | const i = n.lastIndexOf('.') + 1 |
| 125 | this.ext = i ? n.substring(i).toLowerCase() : '' |
| 126 | } |
| 127 | this.c &&= new Date(this.c) |
| 128 | this.m = this.m ? new Date(this.m) : this.c |
| 129 | this.name = n.slice(n.lastIndexOf('/') + 1) |
| 130 | const x = this.isFolder && !this.web ? 'L' : 'R' // to open we need list for folders and read for files |
| 131 | this.cantOpen = this.p?.match(x) ? true : this.p?.match(x.toLowerCase()) ? DirEntry.FORBIDDEN : undefined |
| 132 | } |
| 133 | isRoot() { |
| 134 | return !this.name |
| 135 | } |
| 136 | getNext() { |
| 137 | return this.getSibling(+1) |
| 138 | } |
| 139 | getPrevious() { |
| 140 | return this.getSibling(-1) |
| 141 | } |
| 142 | getNextFiltered() { |
| 143 | return this.getSibling(+1, state.filteredList) |
| 144 | } |
| 145 | getPreviousFiltered() { |
| 146 | return this.getSibling(-1, state.filteredList) |
| 147 | } |
| 148 | getSibling(ofs: number, list: DirList=state.list) { // i'd rather make this private, but valtio is messing with types, causing problems in FilesList() |
| 149 | return list[ofs + list.findIndex(x => x.n === this.n)] |
| 150 | } |
| 151 | |
| 152 | getDefaultIcon() { |
| 153 | return hIcon(this.icon === true ? `${this.uri}?get=icon` : (this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file'))) |
| 154 | } |
| 155 | |
| 156 | canArchive() { |
| 157 | return this.p?.includes('A') || state.props?.can_archive && !this.p?.includes('a') |
| 158 | } |
| 159 | canDelete() { |
| 160 | return !this.isRoot() && (this.p?.includes('D') || state.props?.can_delete_children && !this.p?.includes('d')) |
| 161 | } |
| 162 | canUpload() { |
| 163 | return this.isFolder && (this.p?.includes('U') || state.props?.can_upload && !this.p?.includes('u')) |
| 164 | } |
| 165 | canSelect() { |
| 166 | if (this.url || this.isRoot()) return false |
| 167 | return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins |
| 168 | || hfsEvent('enableEntrySelection', { entry: this }).some(Boolean) |
| 169 | } |
| 170 | } |
| 171 | export type DirList = DirEntry[] |
| 172 | |
| 173 | const exts = { |
| 174 | image: ['jpeg','jpg','gif','png','webp','svg'], |
| 175 | audio: ['mp3','wav','m4a','ogg','flac'], |
| 176 | video: ['mp4','mpeg','mpg','webm','mov','m4v','mkv'], |
| 177 | archive: ['zip', 'rar', 'gz', 'tgz'], |
| 178 | } |
| 179 | |
| 180 | export function ext2type(ext: string) { |
| 181 | return _.findKey(exts, arr => arr.includes(ext)) |
| 182 | } |