├── .npmrc ├── .eslintignore ├── assets ├── full_search.png └── color_search.png ├── src ├── stores │ ├── appStore.ts │ ├── activeStore.ts │ └── pluginStore.ts ├── settings.ts ├── model │ ├── frontmatter.ts │ ├── types │ │ ├── mediaTypes.ts │ │ ├── shape.ts │ │ └── image │ │ │ └── image.ts │ ├── mediaFile.ts │ └── sidecar.ts ├── views │ ├── sidecar-view.ts │ └── gallery-view.ts ├── components │ ├── search │ │ ├── Order.svelte │ │ ├── DateRange.svelte │ │ ├── IncludeSelect.svelte │ │ ├── Popup.svelte │ │ ├── ColourPicker.svelte │ │ └── Resolution.svelte │ ├── MediaFileEmbed.svelte │ ├── Sidecar.svelte │ └── Gallery.svelte ├── mutationHandler.ts ├── cache.ts └── query.ts ├── .editorconfig ├── versions.json ├── manifest.json ├── .gitignore ├── version-bump.mjs ├── .github └── workflows │ └── release.yml ├── tsconfig.json ├── .eslintrc ├── LICENSE.txt ├── package.json ├── README.md ├── esbuild.config.mjs └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /assets/full_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nick-de-Bruin/obsidian-media-companion/HEAD/assets/full_search.png -------------------------------------------------------------------------------- /assets/color_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nick-de-Bruin/obsidian-media-companion/HEAD/assets/color_search.png -------------------------------------------------------------------------------- /src/stores/appStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type { App } from "obsidian"; 3 | 4 | const app = writable(); 5 | export default { app }; 6 | -------------------------------------------------------------------------------- /src/stores/activeStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type MediaFile from "src/model/mediaFile"; 3 | 4 | const file = writable(); 5 | export default { file }; 6 | -------------------------------------------------------------------------------- /src/stores/pluginStore.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | import type MediaCompanion from "main"; 3 | 4 | const plugin = writable(); 5 | export default { plugin }; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.18.0", 3 | "0.1.6": "0.18.0", 4 | "1.0.0": "0.18.0", 5 | "1.0.3": "0.18.0", 6 | "1.1.0": "0.18.0", 7 | "1.1.1": "0.18.0", 8 | "1.1.2": "0.18.0", 9 | "1.1.3": "0.18.0", 10 | "1.1.4": "0.18.0", 11 | "1.1.5": "0.18.0" 12 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export interface MediaCompanionSettings { 2 | hideSidecar: boolean; 3 | extensions: string[]; 4 | sidecarTemplate: string; 5 | } 6 | 7 | export const DEFAULT_SETTINGS: MediaCompanionSettings = { 8 | hideSidecar: true, 9 | extensions: [ 10 | 'png', 11 | 'jpg', 12 | 'jpeg', 13 | 'bmp', 14 | 'avif', 15 | 'webp', 16 | 'gif', 17 | ], 18 | sidecarTemplate: "", 19 | } 20 | -------------------------------------------------------------------------------- /src/model/frontmatter.ts: -------------------------------------------------------------------------------- 1 | // The tags in the frontmatter that are reserved for other purposes 2 | // such as image width and height 3 | export const reservedImageTags: string[] = ["MC-size", "MC-colors"]; 4 | 5 | export const reservedFrontMatterTags: string[] = [...new Set([ 6 | ...reservedImageTags, 7 | ])]; 8 | 9 | // The frontmatter tag used by obsidian to store tags 10 | export const tagsTag = "tags"; 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "media-companion", 3 | "name": "Media Companion", 4 | "version": "1.1.5", 5 | "minAppVersion": "0.18.0", 6 | "description": "Creates a searchable gallery and sidecar files for attachments such as images and videos. The sidecar files allow you to add notes and tags to your media files.", 7 | "author": "Nick de Bruin", 8 | "authorUrl": "https://nickdebruin.me", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # styles.css is generated and should therefore also not be 16 | # uploaded to the repo. Same goes for main.css if it is 17 | # somehow generated but not renamed 18 | main.css 19 | styles.css 20 | 21 | # Exclude sourcemaps 22 | *.map 23 | 24 | # obsidian 25 | data.json 26 | 27 | # Exclude macOS Finder (System Explorer) View States 28 | .DS_Store 29 | -------------------------------------------------------------------------------- /src/model/types/mediaTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The supported media types for the plugin 3 | */ 4 | export enum MediaTypes { 5 | Image = "image", 6 | Unknown = "unknown", 7 | } 8 | 9 | /** 10 | * Finds the media type of a file based on its extension 11 | * @param extention The extension of the file 12 | * @returns The media type of the file 13 | */ 14 | export function getMediaType(extention: string): MediaTypes { 15 | switch (extention) { 16 | case "png": 17 | case "jpg": 18 | case "jpeg": 19 | case "webp": 20 | case "avif": 21 | case "bmp": 22 | case "gif": 23 | return MediaTypes.Image; 24 | default: 25 | return MediaTypes.Unknown; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/model/types/shape.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The shapes something can have 3 | * Used for images 4 | */ 5 | export enum Shape { 6 | Square = "square", 7 | Horizontal = "horizontal", 8 | Vertical = "vertical", 9 | } 10 | 11 | /** 12 | * Finds the shape of an object based on a given width and height 13 | * @param width The width of the object 14 | * @param height The height of the object 15 | * @returns The shape of the object 16 | */ 17 | export function getShape(width: number, height: number): Shape { 18 | if (width === height) { 19 | return Shape.Square; 20 | } else if (width > height) { 21 | return Shape.Horizontal; 22 | } else { 23 | return Shape.Vertical; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css 35 | -------------------------------------------------------------------------------- /src/views/sidecar-view.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import Sidecar from "./../components/Sidecar.svelte"; 3 | import type MediaFile from "src/model/mediaFile"; 4 | 5 | export const VIEW_TYPE_SIDECAR = "media-companion-sidecar-view"; 6 | 7 | export class SidecarView extends ItemView { 8 | component!: Sidecar; 9 | file!: MediaFile; 10 | 11 | constructor(leaf: WorkspaceLeaf) { 12 | super(leaf); 13 | } 14 | 15 | public getViewType() { 16 | return VIEW_TYPE_SIDECAR; 17 | } 18 | 19 | public getDisplayText() { 20 | return "Sidecar"; 21 | } 22 | 23 | public getIcon() { 24 | return "image"; 25 | } 26 | 27 | public async onOpen() { 28 | this.component = new Sidecar({ 29 | target: this.contentEl, 30 | props: { }, 31 | }); 32 | } 33 | 34 | public async onClose() { 35 | if (this.component) { 36 | this.component.$destroy(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "svelte", 6 | "node", 7 | "obsidian-typings", 8 | ], 9 | "baseUrl": ".", 10 | "inlineSources": true, 11 | "module": "ESNext", 12 | "target": "ES6", 13 | "allowJs": true, 14 | "noImplicitAny": true, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "isolatedModules": true, 18 | "strictNullChecks": true, 19 | "lib": [ 20 | "DOM", 21 | "ES5", 22 | "ES6", 23 | "ES7" 24 | ], 25 | "paths": { 26 | "obsidian-typings/implementations": [ 27 | "./node_modules/obsidian-typings/dist/implementations.d.ts", 28 | "./node_modules/obsidian-typings/dist/implementations.cjs" 29 | ] 30 | }, 31 | }, 32 | "include": [ 33 | "**/*.ts", 34 | "**/*.svelte" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/views/gallery-view.ts: -------------------------------------------------------------------------------- 1 | import { ItemView, WorkspaceLeaf } from "obsidian"; 2 | import Gallery from "./../components/Gallery.svelte"; 3 | import type MediaCompanion from "main"; 4 | 5 | export const VIEW_TYPE_GALLERY = "gallery-view"; 6 | 7 | export class GalleryView extends ItemView { 8 | component!: Gallery; 9 | plugin: MediaCompanion; 10 | 11 | public constructor(leaf: WorkspaceLeaf, plugin: MediaCompanion) { 12 | super(leaf); 13 | this.plugin = plugin; 14 | } 15 | 16 | public getViewType() { 17 | return VIEW_TYPE_GALLERY; 18 | } 19 | 20 | public getDisplayText() { 21 | return "Gallery view"; 22 | } 23 | 24 | public getIcon() { 25 | return "image"; 26 | } 27 | 28 | public async onOpen() { 29 | this.contentEl.addClass("MC-gallery-page-container"); 30 | this.component = new Gallery({ 31 | target: this.contentEl, 32 | props: { } 33 | }); 34 | } 35 | 36 | public async onClose() { 37 | this.component.$destroy(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint", 7 | "editorconfig" 8 | ], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:editorconfig/all" 14 | ], 15 | "parserOptions": { 16 | "sourceType": "module", 17 | "project": "./tsconfig.json" 18 | }, 19 | "rules": { 20 | "no-unused-vars": "off", 21 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 22 | "@typescript-eslint/ban-ts-comment": "off", 23 | "no-prototype-builtins": "off", 24 | "@typescript-eslint/no-empty-function": "off", 25 | "@typescript-eslint/await-thenable": "error", // Warns when `await` is used on non-promises 26 | "@typescript-eslint/no-misused-promises": [ 27 | "error", 28 | { 29 | "checksVoidReturn": false // Ensures you don't use `await` on functions with no meaningful return 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nick de Bruin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-media-gallery", 3 | "version": "1.1.5", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@tsconfig/svelte": "^5.0.4", 16 | "@types/imagesloaded": "^4.1.6", 17 | "@types/masonry-layout": "^4.2.8", 18 | "@types/node": "^16.11.6", 19 | "@typescript-eslint/eslint-plugin": "5.29.0", 20 | "@typescript-eslint/parser": "5.29.0", 21 | "builtin-modules": "3.3.0", 22 | "esbuild": "0.25.0", 23 | "esbuild-svelte": "^0.8.2", 24 | "eslint-plugin-editorconfig": "^4.0.3", 25 | "obsidian": "latest", 26 | "obsidian-typings": "^2.3.4", 27 | "svelte": "^4.2.15", 28 | "svelte-preprocess": "^5.1.4", 29 | "tslib": "2.4.0", 30 | "typescript": "^5.4.5" 31 | }, 32 | "dependencies": { 33 | "extract-colors": "^4.0.4", 34 | "imagesloaded": "^5.0.0", 35 | "masonry-layout": "^4.2.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Media Companion Plugin for Obsidian 2 | 3 | > [!CAUTION] 4 | > This plugin creates and edits a file for each media file. Before using it on any serious vault, **make a backup**. 5 | 6 | > [!WARNING] 7 | > The file types this plugin is known to work for have been added in the default settings of the plugin. 8 | > Other formats that Obsidian supports *may* work but they are **not** (yet) officially supported. 9 | > Video files, for example, can currently break the gallery. 10 | 11 | A companion plugin for [Obsidian](https://obsidian.md/) that creates a gallery with all your media files. The plugin aims to let you search through these files. Additionally, it creates sidecar files for each media file, to allow for adding notes, tags, and so on. 12 | 13 | ## Features 14 | 15 | Search through your files based on folders, tags, or file types. 16 | 17 | ![](assets/full_search.png) 18 | 19 | More complex searching can also be done, like searching by color (**without** use of AI!) 20 | 21 | ![](assets/color_search.png) 22 | 23 | *Art shown in the images is from [this dataset of Van Gogh paintings](https://www.kaggle.com/datasets/ipythonx/van-gogh-paintings)* 24 | 25 | ## Planned features 26 | 27 | - [ ] Allow editing of frontmatter data for sidecar files 28 | - [ ] More file type compatibility 29 | - [ ] Video files (mp4, webm) 30 | - [ ] Audio files (mp3, wav) 31 | - [ ] 3d objects (obj, blender files, gltf) 32 | 33 | ## Contributing 34 | 35 | If you wish to contribute to the plugin, feel free to open a pull-request or an issue. 36 | If you're thinking about implementing a large feature, please open an issue first or contact me on discord at `n_1ck` 37 | so we can figure out if it's a good fit for this plugin. 38 | -------------------------------------------------------------------------------- /src/components/search/Order.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | 39 |
40 | 45 | 46 |
47 |
48 | 49 | 74 | -------------------------------------------------------------------------------- /src/components/MediaFileEmbed.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 |
49 |
50 | 51 | 52 | 76 | -------------------------------------------------------------------------------- /src/components/search/DateRange.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 |
27 |
28 |
29 | 30 | 37 |
38 |
39 | 40 | 47 |
48 |
49 |
50 |
51 | 52 | 59 |
60 |
61 | 62 | 69 |
70 |
71 |
72 |
73 | 74 | 89 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import esbuildSvelte from "esbuild-svelte"; 5 | import sveltePreprocess from "svelte-preprocess"; 6 | import fs from "fs/promises" 7 | 8 | const banner = 9 | `/* 10 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 11 | if you want to view the source, please visit the github repository of this plugin 12 | */ 13 | `; 14 | 15 | const prod = (process.argv[2] === "production"); 16 | 17 | const outfile = "main.js"; 18 | const cssOutputFilename = "styles.css"; 19 | 20 | const context = await esbuild.context({ 21 | plugins: [ 22 | esbuildSvelte({ 23 | compilerOptions: { css: "external", cssOutputFilename: "styles.css" }, 24 | preprocess: sveltePreprocess(), 25 | }), 26 | ], 27 | banner: { 28 | js: banner, 29 | }, 30 | entryPoints: ["main.ts"], 31 | bundle: true, 32 | external: [ 33 | "obsidian", 34 | "electron", 35 | "@codemirror/autocomplete", 36 | "@codemirror/collab", 37 | "@codemirror/commands", 38 | "@codemirror/language", 39 | "@codemirror/lint", 40 | "@codemirror/search", 41 | "@codemirror/state", 42 | "@codemirror/view", 43 | "@lezer/common", 44 | "@lezer/highlight", 45 | "@lezer/lr", 46 | ...builtins], 47 | format: "cjs", 48 | target: "es2018", 49 | logLevel: "info", 50 | sourcemap: prod ? false : "inline", 51 | treeShaking: true, 52 | outfile, 53 | }); 54 | 55 | const renameCss = async () => { 56 | const defaultCssOutput = outfile.replace(/\.js$/, ".css"); 57 | try { 58 | await fs.rename(defaultCssOutput, cssOutputFilename); 59 | console.log(`CSS file renamed to ${cssOutputFilename}`); 60 | } catch (error) { 61 | console.error(`Failed to rename CSS file: ${error.message}`); 62 | } 63 | }; 64 | 65 | if (prod) { 66 | await context.rebuild(); 67 | await renameCss(); 68 | process.exit(0); 69 | } else { 70 | await context.watch(); 71 | 72 | // Ugly solution to rename the file on watch as well 73 | // Wasn't able to find a different solution 74 | setInterval(async () => { 75 | const defaultCssOutput = outfile.replace(/\.js$/, ".css"); 76 | 77 | try { 78 | await fs.rename(defaultCssOutput, cssOutputFilename); 79 | console.log("Css renamed"); 80 | } catch (error) {}; 81 | }, 1000); 82 | } 83 | -------------------------------------------------------------------------------- /src/model/mediaFile.ts: -------------------------------------------------------------------------------- 1 | import { TFile, type App } from "obsidian"; 2 | import Sidecar from "./sidecar"; 3 | import { getMediaType, type MediaTypes } from "./types/mediaTypes"; 4 | import type MediaCompanion from "main"; 5 | 6 | export default class MediaFile { 7 | public sidecar!: Sidecar; 8 | public file!: TFile; 9 | protected app!: App; 10 | protected plugin!: MediaCompanion; 11 | 12 | public static last_updated_tag = "MC-last-updated"; 13 | 14 | protected constructor() { } 15 | 16 | /** 17 | * Create a new MediaFile from a binary file 18 | * @param file The file to create a MediaFile from 19 | * @param app The app instance 20 | * @param sidecar The sidecar for the file, in case it already exists 21 | * @returns The created MediaFile 22 | */ 23 | public static async create(file: TFile, app: App, plugin: MediaCompanion, sidecar: TFile | null = null): Promise { 24 | const f = new MediaFile(); 25 | 26 | await MediaFile.fill(f, file, app, plugin, sidecar); 27 | 28 | return f; 29 | } 30 | 31 | /** 32 | * Fill the variables of the MediaFile 33 | * @param f The MediaFile to fill 34 | * @param file The related binary file 35 | * @param app The app instance 36 | * @param sidecar The sidecar for the file, in case it already exists 37 | */ 38 | protected static async fill(f: MediaFile, file: TFile, app: App, plugin: MediaCompanion, sidecar: TFile | null = null): Promise { 39 | f.file = file; 40 | f.app = app; 41 | f.plugin = plugin; 42 | 43 | f.sidecar = await Sidecar.create(file, app, plugin, sidecar); 44 | 45 | await f.update(); 46 | } 47 | 48 | /** 49 | * Finds the mediaType of the file based on the extension of the file 50 | * @returns The MediaType of the file 51 | */ 52 | public getType(): MediaTypes { 53 | return getMediaType(this.file.extension); 54 | } 55 | 56 | /** 57 | * To be called when a file has been updated 58 | */ 59 | public async update(): Promise { 60 | const last_updated = this.sidecar.getFrontmatterTag(MediaFile.last_updated_tag) as number; 61 | 62 | if (!last_updated || 63 | last_updated < this.file.stat.mtime) { 64 | await this.sidecar.setFrontmatterTag(MediaFile.last_updated_tag, new Date(), "datetime"); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/model/sidecar.ts: -------------------------------------------------------------------------------- 1 | import type MediaCompanion from "main"; 2 | import type { App, TFile } from "obsidian"; 3 | import type { FileExplorerLeaf } from "obsidian-typings"; 4 | import pluginStore from "src/stores/pluginStore"; 5 | import { get } from "svelte/store"; 6 | 7 | /** 8 | * Represents a sidecar file for a media file 9 | */ 10 | export default class Sidecar { 11 | public mediaFile!: TFile; 12 | public file!: TFile; 13 | protected app!: App; 14 | protected plugin!: MediaCompanion; 15 | 16 | public static readonly EXTENSION = ".sidecar.md"; 17 | 18 | private constructor() { } 19 | 20 | /** 21 | * Create a new sidecar file and link it to a media file 22 | * @param file The media file to link it to 23 | * @param app The app instance 24 | * @returns The created sidecar 25 | */ 26 | public static async create(mediaFile: TFile, app: App, plugin: MediaCompanion, f: TFile | null = null): Promise { 27 | const file = new Sidecar(); 28 | 29 | file.mediaFile = mediaFile; 30 | file.app = app; 31 | file.plugin = plugin; 32 | 33 | await file.fill(f); 34 | 35 | return file; 36 | } 37 | 38 | /** 39 | * Fill the sidecar with its metadata 40 | * @param file The media file to use for filling 41 | * @param app The app instance 42 | */ 43 | protected async fill(f: TFile | null = null): Promise { 44 | if (f) { 45 | this.file = f; 46 | } else { 47 | this.file = await this.createIfNotExists(); 48 | } 49 | this.hideInAll(); 50 | } 51 | 52 | /** 53 | * Create a sidecar file if it does not exist yet 54 | * @param app The app instance 55 | * @returns The already existing or newly created sidecar file 56 | */ 57 | private async createIfNotExists(): Promise { 58 | const file = this.app.vault.getFileByPath(`${this.mediaFile.path}${Sidecar.EXTENSION}`) ?? 59 | await this.app.vault.create(`${this.mediaFile.path}${Sidecar.EXTENSION}`, this.plugin.settings.sidecarTemplate); 60 | 61 | return file; 62 | } 63 | 64 | private hideInAll(): void { 65 | const leaves = this.app.workspace.getLeavesOfType("file-explorer"); 66 | 67 | for (const leaf of leaves) { 68 | this.hide(leaf); 69 | } 70 | } 71 | 72 | /** 73 | * Hides a sidecar from a given file explorer leaf 74 | * @param leaf The file explorer leaf the sidecar should be hidden form 75 | */ 76 | public hide(leaf: FileExplorerLeaf) { 77 | if (!leaf) return; 78 | // @ts-ignore 79 | if (!leaf.view?.fileItems) return; 80 | const element = leaf.view?.fileItems[this.file.path]?.el; 81 | if (!element) return; 82 | element.hidden = get(pluginStore.plugin).settings.hideSidecar; 83 | } 84 | 85 | /** 86 | * Finds all tags in the file: Both the frontmatter and the body, and returns 87 | * them without duplicates and hashtags. 88 | * @returns The tags, without hashtags and duplicates 89 | */ 90 | public getTags(): string[] { 91 | const cache = this.app.metadataCache.getFileCache(this.file); 92 | 93 | if (!cache) return []; 94 | 95 | let tags = cache.tags?.map(t => t.tag) ?? []; 96 | 97 | const fmTags = cache.frontmatter?.tags ?? []; 98 | 99 | if (Array.isArray(fmTags)) { 100 | tags = tags.concat(fmTags); 101 | } else { 102 | tags.push(fmTags); 103 | } 104 | 105 | // We make it lowercase here and remove dupes; 106 | // For search reasons, we're going to ignore case sensitivity 107 | tags = tags.map(t => t.toLowerCase()); 108 | tags = [...new Set(tags)]; 109 | 110 | // Remove the leading hash 111 | return tags.map(t => t.startsWith("#") ? t.slice(1) : t); 112 | } 113 | 114 | /** 115 | * Gets the information from a tag in the frontmatter 116 | * @param tag The tag to get from the frontmatter 117 | * @returns The data in the tag, or undefined if it does not exist 118 | */ 119 | public getFrontmatterTag(tag: string): unknown | undefined { 120 | const cache = this.app.metadataCache.getFileCache(this.file)?.frontmatter; 121 | if (!cache) return undefined; 122 | 123 | return cache[tag]; 124 | } 125 | 126 | /** 127 | * Sets the information in a tag in the frontmatter 128 | * @param tag The tag to set in the frontmatter 129 | * @param value The value to set 130 | * @param type The type of the frontmatter tag 131 | */ 132 | public async setFrontmatterTag(tag: string, value: unknown, 133 | type: "text" | "multitext" | "number" | "checkbox" | "date" | "datetime" | "aliases" | "tags" | undefined = undefined): Promise { 134 | try { 135 | await this.app.fileManager.processFrontMatter(this.file, (fm) => fm[tag] = value); 136 | 137 | if (type) { 138 | // @ts-ignore 139 | this.app.metadataTypeManager.properties[tag.toLowerCase()].type = type; 140 | } 141 | } catch (e) { 142 | console.error(e); 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/components/search/IncludeSelect.svelte: -------------------------------------------------------------------------------- 1 | 54 | 55 | 56 |
60 |
61 | 67 |
68 | 69 |
70 | {#each filteredOptions as [option, count]} 71 |
72 | {option} 73 | {count} 74 |
75 | 81 | 87 |
88 |
89 | {/each} 90 | {#if filteredOptions.length === 0} 91 |
No results found
92 | {/if} 93 |
94 |
95 |
96 | 97 | 186 | 187 | -------------------------------------------------------------------------------- /src/components/search/Popup.svelte: -------------------------------------------------------------------------------- 1 | 89 | 90 | 104 | 105 | 106 | 107 | 114 | 115 | 178 | -------------------------------------------------------------------------------- /src/model/types/image/image.ts: -------------------------------------------------------------------------------- 1 | import MediaFile from "src/model/mediaFile"; 2 | import type { App, TFile } from "obsidian"; 3 | import { extractColors } from "extract-colors"; 4 | import type MediaCompanion from "main"; 5 | 6 | export default class MCImage extends MediaFile { 7 | // The reserved tags for this type 8 | // when editing these, they should also be renamed in 9 | // frontmatter.ts 10 | public static size_tag = "MC-size"; 11 | public static colors_tag = "MC-colors"; 12 | 13 | protected constructor() { super(); } 14 | 15 | /** 16 | * Create a new MCImage from a file: Use as constructor 17 | * @param file The file to create the image from 18 | * @param app The app instance 19 | * @param sidecar The sidecar file, if it already exists 20 | * @returns The created MCImage 21 | */ 22 | public static async create(file: TFile, app: App, plugin: MediaCompanion, sidecar: TFile | null = null): Promise { 23 | const f = new MCImage(); 24 | 25 | await MCImage.fill(f, file, app, plugin, sidecar) 26 | 27 | return f; 28 | } 29 | 30 | /** 31 | * Fill the properties of a file 32 | * @param f The file to fill 33 | * @param file The related binary file 34 | * @param app The app instance 35 | * @param sidecar The sidecar file, if it already exists 36 | */ 37 | protected static async fill(f: MCImage, file: TFile, app: App, plugin: MediaCompanion, sidecar: TFile | null = null) { 38 | await super.fill(f, file, app, plugin, sidecar); 39 | } 40 | 41 | /** 42 | * Extracts the colors from the image file 43 | * @returns The colors, in the format dictated by the extract-colors package 44 | */ 45 | private async readColors(): Promise<{h: number, s: number, l: number, area: number}[]> { 46 | const extracted = await extractColors( 47 | this.app.vault.getResourcePath(this.file), 48 | // 1/4th of defualt pixels to speed up the proces 49 | {pixels: 16000}); 50 | const colors = []; 51 | 52 | for (const e of extracted) { 53 | colors.push({ 54 | h: e.hue, 55 | s: e.saturation, 56 | l: e.lightness, 57 | area: e.area, 58 | }); 59 | } 60 | 61 | return colors; 62 | } 63 | 64 | /** 65 | * Finds the cahced colors of the image. If they aren't already registered, 66 | * they will be extracted and saved when this is called. 67 | * @returns The cached colors of the image 68 | */ 69 | public async getCachedColors(): Promise { 70 | if (!this.sidecar.getFrontmatterTag(MCImage.colors_tag)) { 71 | await this.setColors(); 72 | } 73 | 74 | return this.sidecar.getFrontmatterTag(MCImage.colors_tag); 75 | } 76 | 77 | /** 78 | * Extracts and sets the colors for the image 79 | */ 80 | private async setColors() { 81 | const colors = await this.readColors(); 82 | await this.sidecar.setFrontmatterTag(MCImage.colors_tag, colors); 83 | } 84 | 85 | /** 86 | * Attempts to parse the given object as an array wit [width, height]. 87 | * Returns undefined if failed 88 | * @param size An object potentially holding the width and height of an image 89 | * @returns The width and height object, undefined if not present 90 | */ 91 | private static parseSize(size: unknown): { width: number, height: number } | undefined { 92 | if (!(size instanceof Array)) return undefined; 93 | 94 | if (size.length !== 2) return undefined; 95 | 96 | return { width: size[0], height: size[1] }; 97 | } 98 | 99 | /** 100 | * Read the width and height from a binary image 101 | * @returns The size of the image 102 | */ 103 | private async readSize(): Promise<{ width: number, height: number }> { 104 | const image = new Image(); 105 | 106 | image.src = this.app.vault.getResourcePath(this.file); 107 | 108 | await image.decode(); 109 | 110 | return { width: image.naturalWidth, height: image.naturalHeight }; 111 | } 112 | 113 | /** 114 | * Finds and returns the size of the image in pixels. 115 | * If there is no size in the cache yet, this will be computed when this is called. 116 | * @returns The cached size 117 | */ 118 | public async getCachedSize(): Promise<{ width: number, height: number } | undefined> { 119 | const value = this.sidecar.getFrontmatterTag(MCImage.size_tag); 120 | 121 | if (!value || !MCImage.parseSize(value)) { 122 | await this.setSize(); 123 | } 124 | 125 | return MCImage.parseSize(this.sidecar.getFrontmatterTag(MCImage.size_tag)); 126 | } 127 | 128 | /** 129 | * Finds and sets the size of the image to the cache 130 | */ 131 | private async setSize() { 132 | const size = await this.readSize(); 133 | await this.sidecar.setFrontmatterTag(MCImage.size_tag, [size.width, size.height]); 134 | } 135 | 136 | /** 137 | * To be called when the file is updated 138 | */ 139 | public async update() { 140 | // If last_updated is older than when the files last updated, update regardless 141 | // or last_updated is not present 142 | 143 | // Or, if one of our things is not cached / can't be parsed 144 | const last_updated = this.sidecar.getFrontmatterTag(MediaFile.last_updated_tag) as number; 145 | 146 | // These methods will set as well when the tag cannot be found 147 | await this.getCachedColors(); 148 | await this.getCachedSize(); 149 | 150 | if (!last_updated || 151 | last_updated < this.file.stat.mtime) { 152 | await this.setColors(); 153 | await this.setSize(); 154 | } 155 | 156 | // Finally, update the last_updated tag 157 | await super.update(); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/components/search/ColourPicker.svelte: -------------------------------------------------------------------------------- 1 | 105 | 106 | 107 |
108 | 109 |
110 |
115 |
116 |
117 | 118 |
122 |
123 |
124 | 125 |
126 | 127 |
131 |
135 |
136 |
137 |
138 |
139 | 140 | 219 | -------------------------------------------------------------------------------- /src/components/search/Resolution.svelte: -------------------------------------------------------------------------------- 1 | 106 | 107 | 108 |
109 |
110 | 116 | 122 | 128 | 134 |
135 | 136 |
137 |
138 |
139 | 140 | 149 |
150 |
151 | 152 | 161 |
162 |
163 |
164 |
165 | 166 | 175 |
176 |
177 | 178 | 187 |
188 |
189 |
190 |
191 |
192 | 193 | 237 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { App, debounce, Plugin, PluginSettingTab, Setting, WorkspaceLeaf } from 'obsidian'; 2 | import { GalleryView, VIEW_TYPE_GALLERY } from 'src/views/gallery-view'; 3 | import { DEFAULT_SETTINGS } from 'src/settings' 4 | import type { MediaCompanionSettings } from 'src/settings'; 5 | import Cache from 'src/cache'; 6 | import MutationHandler from 'src/mutationHandler'; 7 | import pluginStore from 'src/stores/pluginStore'; 8 | import appStore from 'src/stores/appStore'; 9 | import MediaFile from 'src/model/mediaFile'; 10 | import { SidecarView, VIEW_TYPE_SIDECAR } from 'src/views/sidecar-view'; 11 | import activeStore from 'src/stores/activeStore'; 12 | 13 | export default class MediaCompanion extends Plugin { 14 | settings!: MediaCompanionSettings; 15 | cache!: Cache; 16 | mutationHandler!: MutationHandler; 17 | 18 | async onload() { 19 | pluginStore.plugin.set(this); 20 | appStore.app.set(this.app); 21 | 22 | await this.loadSettings(); 23 | 24 | this.cache = new Cache(this.app, this); 25 | this.mutationHandler = new MutationHandler(this.app, this, this.cache); 26 | 27 | // Views should be registered AFTER the cache object and mutationHandler 28 | // are initialized 29 | this.registerViews(); 30 | 31 | this.app.workspace.onLayoutReady(async () => { 32 | await this.cache.initialize(); 33 | 34 | // Register events only after the cache is initialized and the 35 | // layout is ready to avoid many events being sent off 36 | this.registerEvents(); 37 | 38 | // @ts-ignore - Need to set this manually, unsure if there's a better way 39 | this.app.metadataTypeManager.properties[MediaFile.last_updated_tag.toLowerCase()].type = "datetime"; 40 | }); 41 | 42 | this.addRibbonIcon('image', 'Open gallery', (_: MouseEvent) => this.createGallery()); 43 | this.registerCommands(); 44 | 45 | this.addSettingTab(new MediaCompanionSettingTab(this.app, this)); 46 | } 47 | 48 | registerEvents() { 49 | this.mutationHandler.initializeEvents(); 50 | 51 | this.registerEvent(this.app.workspace.on("layout-change", async () => { 52 | const explorers = this.app.workspace.getLeavesOfType("file-explorer"); 53 | for (const explorer of explorers) { 54 | await this.cache.hideAll(explorer); 55 | } 56 | })); 57 | 58 | this.registerEvent(this.app.workspace.on("file-open", async (file) => { 59 | if (file) { 60 | if (this.settings.extensions.contains(file.extension.toLowerCase())) { 61 | const mediaFile = this.cache.getFile(file.path); 62 | if (mediaFile) { 63 | activeStore.file.set(mediaFile); 64 | } 65 | } 66 | } 67 | })); 68 | 69 | activeStore.file.subscribe(async (file) => { 70 | if (file) { 71 | await this.createSidecar(); 72 | } 73 | }); 74 | } 75 | 76 | registerViews() { 77 | this.registerView(VIEW_TYPE_GALLERY, (leaf) => new GalleryView(leaf, this)); 78 | this.registerView(VIEW_TYPE_SIDECAR, (leaf) => new SidecarView(leaf)); 79 | } 80 | 81 | registerCommands() { 82 | this.addCommand({ 83 | id: "open-gallery", 84 | name: "Open gallery", 85 | callback: () => this.createGallery() 86 | }); 87 | } 88 | 89 | async createSidecar(focus = true) { 90 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_SIDECAR); 91 | let leaf: WorkspaceLeaf | null = null; 92 | 93 | if (leaves.length > 0) { 94 | leaf = leaves[0]; 95 | } else { 96 | leaf = this.app.workspace.getRightLeaf(false); 97 | await leaf?.setViewState({type: VIEW_TYPE_SIDECAR, active: true }); 98 | } 99 | 100 | if (leaf && focus) { 101 | this.app.workspace.revealLeaf(leaf); 102 | } 103 | } 104 | 105 | async createGallery() { 106 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_GALLERY); 107 | let leaf: WorkspaceLeaf | null = null; 108 | 109 | if (leaves.length > 0) { 110 | leaf = leaves[0]; 111 | } else { 112 | leaf = this.app.workspace.getLeaf(true); 113 | await leaf?.setViewState({type: VIEW_TYPE_GALLERY, active: true }); 114 | } 115 | 116 | this.app.workspace.revealLeaf(leaf); 117 | } 118 | 119 | async loadSettings() { 120 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 121 | } 122 | 123 | async saveSettings() { 124 | await this.saveData(this.settings); 125 | } 126 | } 127 | 128 | class MediaCompanionSettingTab extends PluginSettingTab { 129 | plugin: MediaCompanion; 130 | 131 | constructor(app: App, plugin: MediaCompanion) { 132 | super(app, plugin); 133 | this.plugin = plugin; 134 | } 135 | 136 | display(): void { 137 | const { containerEl } = this; 138 | 139 | const extensionDebounce = debounce(async (value: string) => { 140 | this.plugin.settings.extensions = value.split(',') 141 | .map((ext) => ext.trim()) 142 | .map((ext) => ext.replace('.', '')) 143 | .filter((ext) => ext.length > 0) 144 | .map((ext) => ext.toLowerCase()) 145 | .filter((ext) => ext !== 'md'); 146 | await this.plugin.saveSettings(); 147 | await this.plugin.cache.updateExtensions(); 148 | }, 500, true); 149 | 150 | containerEl.empty(); 151 | 152 | new Setting(containerEl) 153 | .setName('Hide sidecar files') 154 | .setDesc('(Recommended) Hide sidecar files in the file explorer.') 155 | .addToggle(toggle => toggle 156 | .setValue(this.plugin.settings.hideSidecar) 157 | .onChange(async (value) => { 158 | this.plugin.settings.hideSidecar = value; 159 | await this.plugin.saveSettings(); 160 | })); 161 | 162 | new Setting(containerEl) 163 | .setName('Extensions') 164 | .setDesc('Extensions to be considered as media files, separated by commas.') 165 | .addTextArea(text => text 166 | .setPlaceholder('jpg, png, gif') 167 | .setValue(this.plugin.settings.extensions.join(', ')) 168 | .onChange(async (value) => { 169 | extensionDebounce(value); 170 | })); 171 | 172 | new Setting(containerEl) 173 | .setName('Sidecar template') 174 | .setDesc('The template to be used for new sidecar files.') 175 | .addTextArea(text => text 176 | .setPlaceholder('Sidecar template') 177 | .setValue(this.plugin.settings.sidecarTemplate) 178 | .onChange(async (value) => { 179 | this.plugin.settings.sidecarTemplate = value; 180 | await this.plugin.saveSettings(); 181 | })); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/mutationHandler.ts: -------------------------------------------------------------------------------- 1 | import type MediaCompanion from "main"; 2 | import { TFile, type App, type TAbstractFile } from "obsidian"; 3 | import MediaFile from "./model/mediaFile"; 4 | import type Cache from "./cache"; 5 | import { getMediaType, MediaTypes } from "./model/types/mediaTypes"; 6 | import MCImage from "./model/types/image/image"; 7 | import Sidecar from "./model/sidecar"; 8 | 9 | /** 10 | * Handles mutations in the vault 11 | */ 12 | export default class MutationHandler extends EventTarget { 13 | public app: App; 14 | public plugin: MediaCompanion; 15 | public cache: Cache; 16 | 17 | public constructor(app: App, plugin: MediaCompanion, cache: Cache) { 18 | super(); 19 | 20 | this.app = app; 21 | this.plugin = plugin; 22 | this.cache = cache; 23 | } 24 | 25 | public initializeEvents(): void { 26 | this.plugin.registerEvent(this.app.vault.on("create", this.onFileCreated.bind(this))); 27 | this.plugin.registerEvent(this.app.vault.on("delete", this.onDeleted.bind(this))); 28 | this.plugin.registerEvent(this.app.vault.on("rename", this.onMoved.bind(this))); 29 | this.plugin.registerEvent(this.app.vault.on("modify", this.onFileEdited.bind(this))); 30 | } 31 | 32 | /** 33 | * Callback for file editing 34 | * @param file The edited file 35 | */ 36 | public onFileEdited(file: TAbstractFile): void { 37 | if (!(file instanceof TFile)) return; 38 | 39 | // In case of a markdown file, look for the extension: 40 | const isMarkdown = file.extension === "md"; 41 | 42 | let mediaPath = file.path; 43 | 44 | if (isMarkdown && !file.path.endsWith(Sidecar.EXTENSION)) return; 45 | if (isMarkdown) { 46 | mediaPath = file.path.substring(0, file.path.length - Sidecar.EXTENSION.length); 47 | } 48 | 49 | const f = this.cache.getFile(mediaPath); 50 | 51 | if (f) { 52 | f.update().then(() => {}); 53 | if (isMarkdown) { 54 | this.cache.sidecarUpdated(f); 55 | this.dispatchEvent(new CustomEvent("sidecar-edited", { detail: f })); 56 | } else { 57 | this.dispatchEvent(new CustomEvent("file-edited", { detail: f })); 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Callback for file deletion 64 | * @param file The deleted file 65 | */ 66 | public onDeleted(file: TAbstractFile): void { 67 | if (!(file instanceof TFile)) return; 68 | 69 | // If someone (accidentally) deletes a sidecar file, 70 | // make a new one 71 | const isSidecar = this.cache.isSidecar(file); 72 | if (isSidecar) { 73 | const mediaPath = file.path.substring(0, file.path.length - 11); 74 | const mediaFile = this.app.vault.getFileByPath(mediaPath); 75 | 76 | if (mediaFile) this.onFileCreated(mediaFile); 77 | } 78 | 79 | 80 | if (!this.plugin.settings.extensions.contains(file.extension.toLowerCase())) return; 81 | 82 | // get the file 83 | const f = this.cache.getFile(file.path); 84 | this.cache.removeFile(file); 85 | 86 | if (f) { 87 | this.dispatchEvent(new CustomEvent("file-deleted", { detail: f })); 88 | } 89 | 90 | // Get sidecar file and remove it 91 | const sidecar = this.app.vault.getFileByPath(`${file.path}${Sidecar.EXTENSION}`); 92 | if (sidecar) { 93 | this.app.fileManager.trashFile(sidecar).then(() => {}); 94 | } 95 | } 96 | 97 | /** 98 | * Callback for file moving or renaming 99 | * @param file The new file 100 | * @param oldpath The old path of the file 101 | */ 102 | public onMoved(file: TAbstractFile, oldpath: string): void { 103 | if (!(file instanceof TFile)) return; 104 | 105 | // If someone moved a sidecar file 106 | // Make a new one :( 107 | const isSidecar = this.cache.isSidecarFromPath(oldpath); 108 | 109 | if (isSidecar) { 110 | const mediaPath = oldpath.substring(0, oldpath.length - Sidecar.EXTENSION.length); 111 | const mediaFile = this.app.vault.getFileByPath(mediaPath); 112 | 113 | if (mediaFile) { 114 | this.onFileCreated(mediaFile); 115 | } 116 | } 117 | 118 | if (!this.plugin.settings.extensions.contains(file.extension.toLowerCase())) return; 119 | 120 | const cacheFile = this.cache.getFile(file.path); 121 | const sidecar = this.app.vault.getFileByPath(`${oldpath}${Sidecar.EXTENSION}`); 122 | 123 | if (sidecar) { 124 | this.app.fileManager.renameFile(sidecar, `${file.path}${Sidecar.EXTENSION}`).then(() => {}); 125 | } 126 | 127 | if (!cacheFile) { 128 | this.createMediaFile(file, sidecar).then((mediaFile) => { 129 | if (mediaFile) { 130 | this.cache.fileMoved(mediaFile, oldpath); 131 | this.dispatchEvent(new CustomEvent("file-moved", { detail: {file: mediaFile, oldPath: oldpath} })); 132 | } 133 | }); 134 | } 135 | } 136 | 137 | /** 138 | * Callback for file creation 139 | * @param file The created file 140 | */ 141 | public onFileCreated(file: TAbstractFile): void { 142 | this.createMediaFile(file).then((mediaFile) => { 143 | if (mediaFile) { 144 | this.dispatchEvent(new CustomEvent("file-created", { detail: mediaFile })); 145 | } 146 | }); 147 | } 148 | 149 | /** 150 | * Created a MediaFile of the correct type. E.g.; For an image, an MCImage will be made. 151 | * @param file The file to be made a mediaFile for. Will be checked to be of type TFile and whether its 152 | * extension is in the current plugin settings 153 | * @param sidecar The sidecar file, if there is already one. For example, if the file has been moved. 154 | * @returns The created media file, of the correct type, or null if none was created 155 | */ 156 | private async createMediaFile(file: TAbstractFile, sidecar: TFile | null = null): Promise { 157 | if (!(file instanceof TFile) || !this.plugin.settings.extensions.contains(file.extension.toLowerCase())) return null; 158 | 159 | // Make sure it is not already in the cache 160 | if (this.cache.getFile(file.path)) return null; 161 | 162 | let mediaFile = null; 163 | 164 | switch (getMediaType(file.extension)) { 165 | case MediaTypes.Image: 166 | mediaFile = await MCImage.create(file, this.app, this.plugin, sidecar); 167 | break; 168 | case MediaTypes.Unknown: 169 | mediaFile = await MediaFile.create(file, this.app, this.plugin, sidecar); 170 | break; 171 | } 172 | 173 | if (mediaFile) { 174 | this.cache.addFile(mediaFile); 175 | } 176 | 177 | return mediaFile; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/components/Sidecar.svelte: -------------------------------------------------------------------------------- 1 | 203 | 204 |
205 | {#if !file} 206 |

No file selected

207 | {:else} 208 | {#if file.file} 209 |
210 | 211 |
212 | {/if} 213 | {/if} 214 | 215 | {#if invalidName} 216 |

Invalid filename

217 | {/if} 218 | 219 | 220 |
221 | 222 | 276 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { App, TFile } from "obsidian"; 2 | import MediaFile from "./model/mediaFile"; 3 | import type MediaCompanion from "main"; 4 | import { getMediaType, MediaTypes } from "./model/types/mediaTypes"; 5 | import MCImage from "./model/types/image/image"; 6 | import type { FileExplorerLeaf } from "obsidian-typings"; 7 | import Sidecar from "./model/sidecar"; 8 | 9 | /** 10 | * Represents a cache for media files 11 | */ 12 | export default class Cache { 13 | // The cached files 14 | public files: MediaFile[]; 15 | 16 | public paths: {[key: string]: number} = {}; 17 | public tags: {[key: string]: number} = {}; 18 | public extensions: {[key: string]: number} = {}; 19 | 20 | private app: App; 21 | private plugin: MediaCompanion; 22 | 23 | // Whether there is files currently being added or removed from the cache 24 | private building = false; 25 | private initialized = false; 26 | 27 | public constructor(app: App, plugin: MediaCompanion) { 28 | this.files = []; 29 | 30 | this.app = app; 31 | this.plugin = plugin; 32 | } 33 | 34 | /** 35 | * Will wait in steps of 100ms until the cache is initialized 36 | */ 37 | public async awaitReady(): Promise { 38 | await this.initialize(); 39 | if (this.building || !this.initialized) { 40 | while (this.building || !this.initialized) { 41 | await new Promise(resolve => setTimeout(resolve, 100)); 42 | } 43 | } 44 | } 45 | 46 | /** 47 | * Initialize the cache with all the (supported) files in the vault 48 | */ 49 | public async initialize(): Promise { 50 | if (this.initialized) return; 51 | 52 | // Prevent multiple initializations 53 | if (this.building) { 54 | while (this.building) { 55 | await new Promise(resolve => setTimeout(resolve, 100)); 56 | } 57 | return; 58 | } 59 | 60 | this.building = true; 61 | 62 | while (this.app.metadataCache.inProgressTaskCount > 0) { 63 | await new Promise(resolve => setTimeout(resolve, 100)); 64 | } 65 | 66 | let files = this.app.vault.getFiles(); 67 | 68 | const timer = Date.now(); 69 | 70 | const total_files = files.length; 71 | 72 | files = files.filter(f => this.plugin.settings.extensions.contains(f.extension.toLowerCase())); 73 | 74 | console.debug( 75 | `%c[Media Companion]: %cBuilding cache with ${files.length} media files found of ${total_files} files total \n If this is the first time, this may take a while`, 76 | "color: #00b7eb", "color: inherit" 77 | ); 78 | 79 | const notice = new Notice(`Building cache with ${files.length} media files found of ${total_files} files total\nProcessing may take a while if many new files have been added`, 0); 80 | 81 | let total_done = 0; 82 | 83 | for (const file of files) { 84 | let mediaFile; 85 | 86 | try { 87 | switch (getMediaType(file.extension)) { 88 | case MediaTypes.Image: 89 | mediaFile = await MCImage.create(file, this.app, this.plugin); 90 | break; 91 | case MediaTypes.Unknown: 92 | default: 93 | mediaFile = await MediaFile.create(file, this.app, this.plugin); 94 | break; 95 | } 96 | 97 | this.addFile(mediaFile); 98 | } catch (e) { 99 | console.log(`Failed on ${file.name}`); 100 | console.log(e); 101 | } 102 | 103 | total_done++; 104 | notice.setMessage(`Media Companion: ${total_done}/${files.length} files processed\nProcessing may take a while if many new files have been added`); 105 | } 106 | 107 | notice.hide(); 108 | 109 | console.debug( 110 | `%c[Media Companion]: %cFinished building cache in ${(Date.now() - timer) / 1000}s, ${this.files.length} files in cache`, 111 | "color: #00b7eb", "color: inherit" 112 | ); 113 | 114 | this.initialized = true; 115 | this.building = false; 116 | } 117 | 118 | /** 119 | * Will add new files to the cache or remove unneeded ones. 120 | * Should be called whenever plugin.settings.extensions is changed. 121 | */ 122 | public async updateExtensions(): Promise { 123 | if (this.building) { 124 | while (this.building) { 125 | await new Promise(resolve => setTimeout(resolve, 100)); 126 | } 127 | } 128 | 129 | this.building = true; 130 | 131 | this.files = this.files.filter(f => this.plugin.settings.extensions.contains(f.file.extension.toLowerCase())); 132 | 133 | let files = this.app.vault.getFiles(); 134 | 135 | files = files.filter(f => this.plugin.settings.extensions.contains(f.extension.toLowerCase())); 136 | // This is an awful way to do this; It's O(N^2) - Should improve at some point 137 | files = files.filter(f => !this.files.filter(mf => mf.file.path == f.path)); 138 | 139 | const notice = new Notice(`Adding ${files.length} new files`, 0); 140 | 141 | let total_done = 0; 142 | 143 | for (const file of files) { 144 | let mediaFile; 145 | 146 | switch (getMediaType(file.extension)) { 147 | case MediaTypes.Image: 148 | mediaFile = await MCImage.create(file, this.app, this.plugin); 149 | break; 150 | case MediaTypes.Unknown: 151 | default: 152 | mediaFile = await MediaFile.create(file, this.app, this.plugin); 153 | break; 154 | } 155 | 156 | this.addFile(mediaFile); 157 | 158 | total_done++; 159 | notice.setMessage(`Media Companion: ${total_done}/${files.length} new files added\nProcessing may take a while if many new files have been added`); 160 | } 161 | 162 | notice.hide(); 163 | this.building = false; 164 | } 165 | 166 | /** 167 | * Add a file to the cache 168 | * @param file The file to add to the cache 169 | */ 170 | public addFile(file: MediaFile): void { 171 | this.files.push(file); 172 | 173 | // Update paths 174 | if (file.file.parent?.path) { 175 | for (const path of this.getPathHierarchy(file.file.parent?.path)) { 176 | this.addCounter(this.paths, path); 177 | } 178 | } 179 | 180 | // Update extensions 181 | this.addCounter(this.extensions, file.file.extension); 182 | 183 | // Update tags 184 | const tags = file.sidecar.getTags(); 185 | if (tags.length > 0) { 186 | for (const tag of tags) { 187 | for (const path of this.getPathHierarchy(tag)) { 188 | this.addCounter(this.tags, path); 189 | } 190 | } 191 | } 192 | } 193 | 194 | /** 195 | * Remove a file from the cache 196 | * @param file The file to remove 197 | * @returns Whether the operation removed a file or not 198 | */ 199 | public removeFile(file: TFile): boolean { 200 | const mediaFile = this.files.find(f => f.file === file); 201 | 202 | if (mediaFile) { 203 | this.files = this.files.filter(f => f.file !== file); 204 | 205 | // Update paths 206 | if (mediaFile.file.parent?.path) { 207 | for (const path of this.getPathHierarchy(mediaFile.file.parent?.path)) { 208 | this.removeCounter(this.paths, path); 209 | } 210 | } 211 | 212 | // Update extensions 213 | this.removeCounter(this.extensions, mediaFile.file.extension); 214 | 215 | // Update tags 216 | const tags = mediaFile.sidecar.getTags(); 217 | if (tags.length > 0) { 218 | for (const tag of tags) { 219 | for (const path of this.getPathHierarchy(tag)) { 220 | this.removeCounter(this.tags, path); 221 | } 222 | } 223 | } 224 | 225 | return true; 226 | } 227 | 228 | return false; 229 | } 230 | 231 | public fileMoved(file: MediaFile, oldPath: string) { 232 | if (file.file.parent?.path) { 233 | for (const path of this.getPathHierarchy(file.file.parent?.path)) { 234 | this.addCounter(this.paths, path); 235 | } 236 | } 237 | 238 | for (const path of this.getPathHierarchy(oldPath)) { 239 | this.removeCounter(this.paths, path); 240 | } 241 | } 242 | 243 | public sidecarUpdated(_: MediaFile) { 244 | // Rebuild the entire cache for tags 245 | this.tags = {}; 246 | 247 | for (const mFile of this.files) { 248 | for (const tag of mFile.sidecar.getTags()) { 249 | for (const path of this.getPathHierarchy(tag)) { 250 | this.addCounter(this.tags, path); 251 | } 252 | } 253 | } 254 | } 255 | 256 | private addCounter(counter: {[key: string]: number}, value: string) { 257 | if (counter[value]) { 258 | counter[value] += 1; 259 | } else { 260 | counter[value] = 1; 261 | } 262 | } 263 | 264 | private removeCounter(counter: {[key: string]: number}, value: string) { 265 | if (counter[value]) { 266 | counter[value] -= 1; 267 | 268 | if (this.extensions[value] == 0) { 269 | delete this.extensions[value]; 270 | } 271 | } 272 | } 273 | 274 | /** 275 | * Returns all parent paths for a given path, from root to full path 276 | * @param path The file path to process 277 | * @returns Array of path strings, from root to full path 278 | */ 279 | private getPathHierarchy(path: string): string[] { 280 | if (!path) return ['']; 281 | 282 | const segments = path.split('/'); 283 | const paths: string[] = []; 284 | 285 | // Start with root 286 | let currentPath = ''; 287 | 288 | // Build each level of the path 289 | for (let i = 0; i < segments.length; i++) { 290 | if (currentPath && segments[i]) { 291 | currentPath += '/'; 292 | } 293 | currentPath += segments[i]; 294 | if (currentPath) { 295 | paths.push(currentPath); 296 | } 297 | } 298 | 299 | return paths; 300 | } 301 | 302 | /** 303 | * Get a file from the cache 304 | * @param path The path of the file to get from the cache 305 | * @returns The file if it exists in the cache, otherwise undefined 306 | */ 307 | public getFile(path: string): MediaFile | undefined { 308 | return this.files.find(f => f.file.path === path); 309 | } 310 | 311 | /** 312 | * Checks whether a file is a sidecar file managed by the plugin 313 | * @param file The file to validate 314 | * @returns Whether or not it is a sidecar file managed by the plugin 315 | */ 316 | public isSidecar(file: TFile): boolean { 317 | return this.isSidecarFromPath(file.path); 318 | } 319 | 320 | public isSidecarFromPath(path: string): boolean { 321 | // Check if the path ends with the sidecar extension 322 | if (!path.endsWith(Sidecar.EXTENSION)) return false; 323 | 324 | // Else, get the media file and check if it exists 325 | const mediaPath = path.substring(0, path.length - Sidecar.EXTENSION.length); 326 | const mediaFile = this.getFile(mediaPath); 327 | 328 | return mediaFile !== undefined; 329 | } 330 | 331 | /** 332 | * Takes every file in the cache and calls the function to hide itself from the 333 | * given file explorer leaf. 334 | * @param leaf The file manager leaf to hide things from 335 | */ 336 | public async hideAll(leaf: FileExplorerLeaf): Promise { 337 | for (const file of this.files) { 338 | file.sidecar.hide(leaf); 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import type { HexString } from "obsidian"; 2 | import Cache from "./cache"; 3 | import { getShape, type Shape } from "./model/types/shape"; 4 | import type MediaFile from "./model/mediaFile"; 5 | import { MediaTypes } from "./model/types/mediaTypes"; 6 | import type MCImage from "./model/types/image/image"; 7 | 8 | /** 9 | * OrderBy options for the gallery. 10 | * Options are in camelCase 11 | */ 12 | export enum OrderByOptions { 13 | random = "random", 14 | creationDate = "creationDate", 15 | modifiedDate = "modifiedDate", 16 | name = "name", 17 | } 18 | 19 | /** 20 | * A query of media files 21 | */ 22 | export type QueryDetails = { 23 | name: string, // string, if empty, all names 24 | folders: { 25 | included: string[], 26 | excluded: string[], 27 | }, 28 | tags: { 29 | included: string[], 30 | excluded: string[], 31 | }, 32 | fileTypes: { 33 | included: string[], 34 | excluded: string[], 35 | }, 36 | color: HexString | null, 37 | shape: Shape | null, 38 | dimensions: { 39 | minWidth: number | null, 40 | maxWidth: number | null, 41 | minHeight: number | null, 42 | maxHeight: number | null, 43 | }, 44 | date: { 45 | startCtime: Date | null, 46 | endCtime: Date | null, 47 | startMtime: Date | null, 48 | endMtime: Date | null 49 | } 50 | orderBy: OrderByOptions, 51 | orderIncreasing: boolean, 52 | } 53 | 54 | /** 55 | * An object to handle search queries for the cache 56 | */ 57 | export default class Query { 58 | private cache: Cache; 59 | private files: MediaFile[]; 60 | private query: QueryDetails; 61 | private currentIndex: number; 62 | private totalFound: number; 63 | public static readonly defaultQuery: QueryDetails = { 64 | name: "", 65 | folders: { 66 | included: [], 67 | excluded: [], 68 | }, 69 | tags: { 70 | included: [], 71 | excluded: [], 72 | }, 73 | fileTypes: { 74 | included: [], 75 | excluded: [] 76 | }, 77 | color: null, 78 | shape: null, 79 | dimensions: { 80 | minWidth: null, 81 | maxWidth: null, 82 | minHeight: null, 83 | maxHeight: null, 84 | }, 85 | date: { 86 | startCtime: null, 87 | endCtime: null, 88 | startMtime: null, 89 | endMtime: null 90 | }, 91 | orderBy: OrderByOptions.name, 92 | orderIncreasing: true, 93 | } 94 | 95 | private sortedFolders: [string, boolean][]; 96 | private sortedTags: [string, boolean][]; 97 | private anyFolderInclude: boolean; 98 | private anyTagInclude: boolean; 99 | 100 | public constructor(cache: Cache, query: QueryDetails = Query.defaultQuery) { 101 | this.cache = cache; 102 | this.query = query; 103 | this.currentIndex = -1; 104 | this.totalFound = 0; 105 | this.files = []; 106 | 107 | this.anyFolderInclude = this.query.folders.included.length > 0; 108 | this.anyTagInclude = this.query.tags.included.length > 0; 109 | 110 | this.sortedFolders = [ 111 | ...this.query.folders.included.map(folder => [folder, true] as [string, boolean]), 112 | ...this.query.folders.excluded.map(folder => [folder, false] as [string, boolean]) 113 | ]; 114 | 115 | this.sortedFolders.sort((a, b) => b[0].length - a[0].length); 116 | 117 | this.sortedTags = [ 118 | ...this.query.tags.included.map(tags => [tags, true] as [string, boolean]), 119 | ...this.query.tags.excluded.map(tags => [tags, false] as [string, boolean]) 120 | ] 121 | 122 | this.sortedTags.sort((a, b) => b[0].length - a[0].length); 123 | } 124 | 125 | /** 126 | * Orders the files in the query according to the 127 | * OrderByOptions field in the QueryDetails 128 | */ 129 | public orderFiles() { 130 | switch (this.query.orderBy) { 131 | case OrderByOptions.creationDate: 132 | this.files.sort((a, b) => a.file.stat.ctime - b.file.stat.ctime); 133 | break; 134 | case OrderByOptions.modifiedDate: 135 | this.files.sort((a, b) => a.file.stat.mtime - b.file.stat.mtime); 136 | break; 137 | case OrderByOptions.name: 138 | this.files.sort((a, b) => a.file.name.localeCompare(b.file.name)); 139 | break; 140 | case OrderByOptions.random: 141 | default: 142 | this.files.sort(() => Math.random() - 0.5); 143 | break; 144 | } 145 | 146 | if (!this.query.orderIncreasing) { 147 | this.files.reverse(); 148 | } 149 | } 150 | 151 | /** 152 | * Tests a given file and returns whether the file fits 153 | * the query 154 | * @param item The MediaFile to be tested 155 | * @returns Whether the file fits the query 156 | */ 157 | public async testFile(item: MediaFile): Promise { 158 | const mediaTypes = this.determineTypes(); 159 | 160 | if (mediaTypes.length > 0) { 161 | if (!mediaTypes.includes(item.getType())) return false; 162 | } 163 | 164 | if (this.query.fileTypes.included.length > 0 && !this.query.fileTypes.included.contains(item.file.extension)) return false; 165 | if (this.query.fileTypes.excluded.contains(item.file.extension)) return false; 166 | 167 | const DAY_LENGTH = 86400000; // 1000* 60 * 60 * 24 168 | 169 | if (this.query.date.startCtime && this.query.date.endCtime && 170 | this.query.date.startCtime.getTime() < this.query.date.endCtime.getTime() + DAY_LENGTH && 171 | (item.file.stat.ctime < this.query.date.startCtime.getTime() || 172 | item.file.stat.ctime > (this.query.date.endCtime.getTime()) + DAY_LENGTH)) return false; 173 | 174 | if (this.query.date.startMtime && this.query.date.endMtime && 175 | this.query.date.startMtime.getTime() < this.query.date.endMtime.getTime() + DAY_LENGTH && 176 | (item.file.stat.mtime < (this.query.date.startMtime.getTime()) || 177 | item.file.stat.mtime > (this.query.date.endMtime.getTime()) + DAY_LENGTH)) return false; 178 | 179 | if (mediaTypes.contains(MediaTypes.Image)) { 180 | const image = item as MCImage; 181 | const size = await image.getCachedSize(); 182 | 183 | if (size) { 184 | if (this.query.dimensions.minWidth && size.width < this.query.dimensions.minWidth) return false; 185 | if (this.query.dimensions.maxWidth && size.width > this.query.dimensions.maxWidth) return false; 186 | if (this.query.dimensions.minHeight && size.height < this.query.dimensions.minHeight) return false; 187 | if (this.query.dimensions.maxHeight && size.height > this.query.dimensions.maxHeight) return false; 188 | 189 | if (this.query.shape) { 190 | if (this.query.shape !== getShape(size.width, size.height)) return false; 191 | } 192 | } 193 | 194 | if (this.query.color) { 195 | // From: https://gist.github.com/vahidk/05184faf3d92a0aa1b46aeaa93b07786 196 | // No attribution required, but here it is anyway 197 | // eslint-disable-next-line no-inner-declarations 198 | function rgbToHsl(r: number, g: number, b: number): [number, number, number] { 199 | r /= 255; g /= 255; b /= 255; 200 | const max = Math.max(r, g, b); 201 | const min = Math.min(r, g, b); 202 | const d = max - min; 203 | let h = 0; 204 | if (d === 0) h = 0; 205 | else if (max === r) h = (g - b) / d % 6; 206 | else if (max === g) h = (b - r) / d + 2; 207 | else if (max === b) h = (r - g) / d + 4; 208 | const l = (min + max) / 2; 209 | const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); 210 | return [h * 60, s, l]; 211 | } 212 | 213 | // Extract RGB values from the color hex string 214 | const color = this.query.color; 215 | const r = parseInt(color.substring(1, 3), 16); 216 | const g = parseInt(color.substring(3, 5), 16); 217 | const b = parseInt(color.substring(5, 7), 16); 218 | 219 | const hsl = rgbToHsl(r, g, b); 220 | 221 | const h = hsl[0]; 222 | const s = hsl[1]; 223 | const l = hsl[2]; 224 | 225 | let distance = 0; 226 | 227 | const colors = await image.getCachedColors() 228 | 229 | if (!colors || !Array.isArray(colors)) return false; 230 | if (colors.length === 0) return false; 231 | 232 | for (const color of colors) { 233 | const ch = color.h * 360; 234 | const cs = color.s; 235 | const cl = color.l; 236 | 237 | // Handle hue wrap around 238 | // In HSL, the hue is a value from 0 to 360 239 | // where in practice, 0 and 360 are the same 240 | // Imagine them as a circle, where 0 and 360 degrees are the same point 241 | const hDiff = Math.min(Math.abs(ch - h), Math.abs(ch - h + 360)); 242 | const sDiff = Math.abs(cs - s); 243 | const lDiff = Math.abs(cl - l); 244 | 245 | // Completely arbitrary, might want to tweak 246 | distance += (hDiff / 180 + sDiff + lDiff) * color.area; 247 | 248 | // Completely arbitrary, might want to tweak 249 | // Break out in the for loop so we don't compute more than we need to 250 | if (distance > 0.5) return false; 251 | } 252 | } 253 | } 254 | 255 | if (this.query.name.length > 0) { 256 | if (!item.file.basename.toLowerCase().includes(this.query.name.toLowerCase())) return false; 257 | } 258 | 259 | // Folders... 260 | let foundIncludeFolder = false; 261 | for (const [folder, include] of this.sortedFolders) { 262 | if (item.file.path.startsWith(folder)) { 263 | if (!include) return false; 264 | else foundIncludeFolder = true; 265 | break; 266 | } 267 | } 268 | 269 | if (this.anyFolderInclude && !foundIncludeFolder) return false; 270 | 271 | // Tags... 272 | let foundIncludeTag = false; 273 | 274 | tagsLoop: for (const mTag of item.sidecar.getTags()) { 275 | for (const [tag, include] of this.sortedTags) { 276 | if (mTag.startsWith(tag)) { 277 | if (!include) return false; 278 | else foundIncludeTag = true; 279 | continue tagsLoop; 280 | } 281 | } 282 | } 283 | 284 | if (this.anyTagInclude && !foundIncludeTag) return false; 285 | 286 | return true; 287 | } 288 | 289 | /** 290 | * Filters all mediafiles from the cache and returns the ones 291 | * that fit the query. Will wait for the cache to be ready 292 | * @returns All MediaFiles from the cache that fit the query 293 | */ 294 | public async getItems(): Promise { 295 | await this.cache.awaitReady(); 296 | 297 | this.files = [...this.cache.files]; 298 | 299 | this.orderFiles(); 300 | 301 | const found = []; 302 | 303 | while (this.currentIndex < this.cache.files.length - 1) { 304 | this.currentIndex++; 305 | 306 | const item = this.files[this.currentIndex]; 307 | 308 | if (await this.testFile(item)) { 309 | found.push(item); 310 | this.totalFound++; 311 | } 312 | } 313 | return found; 314 | } 315 | 316 | /** 317 | * Finds the types of files that the user could be querying based on the 318 | * parameters given in the query 319 | * @returns The possible types of files that can be queried with the current 320 | * query parameters 321 | */ 322 | private determineTypes(): MediaTypes[] { 323 | if ((this.query.dimensions 324 | && (this.query.dimensions.maxHeight !== null 325 | || this.query.dimensions.maxWidth !== null 326 | || this.query.dimensions.minHeight !== null 327 | || this.query.dimensions.minWidth !== null)) 328 | || this.query.shape !== null 329 | || this.query.color !== null) { 330 | // May in the future also be video 331 | return [MediaTypes.Image]; 332 | } 333 | return []; 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/components/Gallery.svelte: -------------------------------------------------------------------------------- 1 | 437 | 438 | 549 | 550 | 637 | --------------------------------------------------------------------------------