forked from GitHubMirrors/silverbullet-icalendar
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb30ab6b3 | ||
|
|
904c1b9d94 | ||
|
|
34bbe69569 | ||
|
|
38dd97c25c | ||
|
|
d3e4fc021b | ||
|
|
8a7c9700ee | ||
|
|
e12420aba3 | ||
|
|
4df5a1f8a8 | ||
|
|
e13e6e2bc2 |
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 Marek S. Lukasiewicz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
45
README.md
45
README.md
@@ -3,6 +3,8 @@
|
||||
`silverbullet-icalendar` is a [Plug](https://silverbullet.md/Plugs) for [SilverBullet](https://silverbullet.md/) which I made for my girlfriend.
|
||||
It reads external [iCalendar](https://en.wikipedia.org/wiki/ICalendar) data, also known as iCal and `.ics` format, used in CalDAV protocol.
|
||||
|
||||
**Note**: This version (0.2.0+) is compatible with **SilverBullet v2 only**. For SilverBullet v1, use version 0.1.0.
|
||||
|
||||
## Installation
|
||||
|
||||
Run the {[Plugs: Add]} command in SilverBullet and add paste this URI into the dialog box:
|
||||
@@ -15,7 +17,7 @@ Then run the {[Plugs: Update]} command and off you go!
|
||||
|
||||
### Configuration
|
||||
|
||||
This plug can be configured with [Space Config](https://silverbullet.md/Space%20Config), these are the default values and their usage:
|
||||
This plug is configured with [Space Config](https://silverbullet.md/Space%20Config), short example:
|
||||
|
||||
```yaml
|
||||
icalendar:
|
||||
@@ -37,34 +39,55 @@ Instructions to get the source URL for some calendar services:
|
||||
- Calendar settings (pencil icon to the right of the name)
|
||||
- Settings and Sharing, scroll down to Integrate calendar
|
||||
- Copy the link for Secret address in iCal format
|
||||
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
The plug provides the query source `ical-event`, which corresponds to `VEVENT` object
|
||||
After configuration, run the `{[iCalendar: Sync]}` command to synchronize calendar events. The plug will cache the results for 6 hours by default (configurable via `cacheDuration` in config).
|
||||
|
||||
To bypass the cache and force an immediate sync, use the `{[iCalendar: Force Sync]}` command.
|
||||
|
||||
To completely clear all indexed events and cache (useful for troubleshooting), use the `{[iCalendar: Clear All Events]}` command.
|
||||
|
||||
Events are indexed with the tag `ical-event` and can be queried using Lua Integrated Query (LIQ).
|
||||
|
||||
### Examples
|
||||
|
||||
Select events that start on a given date
|
||||
Select events that start on a given date:
|
||||
|
||||
~~~
|
||||
```query
|
||||
ical-event
|
||||
where start =~ /^2024-01-04/
|
||||
select summary, description
|
||||
```md
|
||||
${query[[
|
||||
from index.tag "ical-event"
|
||||
where start:startsWith "2024-01-04"
|
||||
select {summary=summary, description=description}
|
||||
]]}
|
||||
```
|
||||
~~~
|
||||
|
||||
Get the next 5 upcoming events:
|
||||
```md
|
||||
${query[[
|
||||
from index.tag "ical-event"
|
||||
where start > os.date("%Y-%m-%d")
|
||||
order by start
|
||||
limit 5
|
||||
]]}
|
||||
```
|
||||
~~~
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`, command for manual update
|
||||
- More query sources:
|
||||
- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`
|
||||
- More indexed object types:
|
||||
- `ical-todo` for `VTODO` components
|
||||
- `ical-calendar` showing information about configured calendars
|
||||
- Describe the properties of query results
|
||||
- Support `file://` URL scheme (use an external script or filesystem instead of authentication on CalDAV)
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests with short instructions for various calendar services are welcome.
|
||||
If you find bugs, report them on the [issue tracker on GitHub](https://github.com/Maarrk/silverbullet-icalendar/issues).
|
||||
|
||||
### Building from source
|
||||
|
||||
11
deno.jsonc
11
deno.jsonc
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"tasks": {
|
||||
"build": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml",
|
||||
"build:debug": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml --debug",
|
||||
"watch": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml -w"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": ["no-explicit-any"]
|
||||
"exclude": [
|
||||
"no-explicit-any"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
@@ -16,7 +19,7 @@
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^0.10.1",
|
||||
"ts-ics": "npm:ts-ics@1.6.5"
|
||||
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.0.0",
|
||||
"ts-ics": "npm:ts-ics@2.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,28 @@ name: icalendar
|
||||
requiredPermissions:
|
||||
- fetch
|
||||
functions:
|
||||
syncCalendars:
|
||||
path: ./icalendar.ts:syncCalendars
|
||||
command:
|
||||
name: "iCalendar: Sync"
|
||||
priority: -1
|
||||
events:
|
||||
- editor:init
|
||||
forceSync:
|
||||
path: ./icalendar.ts:forceSync
|
||||
command:
|
||||
name: "iCalendar: Force Sync"
|
||||
priority: -1
|
||||
clearCache:
|
||||
path: ./icalendar.ts:clearCache
|
||||
command:
|
||||
name: "iCalendar: Clear All Events"
|
||||
priority: -1
|
||||
showVersion:
|
||||
path: ./icalendar.ts:showVersion
|
||||
command:
|
||||
name: "iCalendar: Version"
|
||||
priority: -2
|
||||
queryEvents:
|
||||
path: ./icalendar.ts:queryEvents
|
||||
events:
|
||||
- query:ical-event
|
||||
config:
|
||||
schema.config.properties.icalendar:
|
||||
type: object
|
||||
@@ -29,3 +42,6 @@ config:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
cacheDuration:
|
||||
type: number
|
||||
description: "Interval between two calendar synchronizations (default: 21600 = 6 hours)"
|
||||
|
||||
363
icalendar.ts
363
icalendar.ts
@@ -1,127 +1,306 @@
|
||||
import { editor, system } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { QueryProviderEvent } from "@silverbulletmd/silverbullet/types";
|
||||
import { applyQuery } from "@silverbulletmd/silverbullet/lib/query";
|
||||
import { parseIcsCalendar, type VCalendar } from "ts-ics";
|
||||
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||
import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
|
||||
import { convertIcsCalendar, type IcsCalendar, type IcsEvent, type IcsDateObjects } from "ts-ics";
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
// Try to match SilverBullet properties where possible.
|
||||
// Timestamps should be strings formatted with `localDateString`
|
||||
interface Event {
|
||||
// Typically available in calendar apps
|
||||
summary: string | undefined;
|
||||
description: string | undefined;
|
||||
location: string | undefined;
|
||||
const VERSION = "0.2.1";
|
||||
const CACHE_KEY = "icalendar:lastSync";
|
||||
const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours
|
||||
|
||||
// Same as SilverBullet pages
|
||||
created: string | undefined;
|
||||
lastModified: string | undefined;
|
||||
// Keep consistent with dates above
|
||||
start: string | undefined;
|
||||
end: string | undefined;
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Recursively converts all Date objects to strings in a type
|
||||
*/
|
||||
type DateToString<T> = T extends Date ? string
|
||||
: T extends IcsDateObjects ? string
|
||||
: T extends object ? { [K in keyof T]: DateToString<T[K]> }
|
||||
: T extends Array<infer U> ? Array<DateToString<U>>
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Configuration for a calendar source
|
||||
*/
|
||||
interface Source {
|
||||
url: string;
|
||||
name: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin configuration structure
|
||||
*/
|
||||
interface PlugConfig {
|
||||
sources: Source[];
|
||||
cacheDuration: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar event object indexed in SilverBullet
|
||||
* Queryable via: `ical-event` from index
|
||||
*
|
||||
* Extends IcsEvent with all Date fields converted to strings recursively
|
||||
*/
|
||||
interface CalendarEvent extends DateToString<IcsEvent> {
|
||||
ref: string;
|
||||
tag: "ical-event";
|
||||
sourceName: string | undefined;
|
||||
}
|
||||
|
||||
interface Source {
|
||||
url: string; // Should be an .ics file
|
||||
name: string | undefined; // Optional name that will be assigned to events
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard for IcsDateObjects
|
||||
*/
|
||||
function isIcsDateObjects(obj: any): obj is IcsDateObjects {
|
||||
return obj && typeof obj === 'object' && ('date' in obj && 'type' in obj);
|
||||
}
|
||||
|
||||
export async function queryEvents(
|
||||
{ query }: QueryProviderEvent,
|
||||
): Promise<any[]> {
|
||||
const events: Event[] = [];
|
||||
/**
|
||||
* Creates a SHA-256 hash of a string (hex encoded)
|
||||
*/
|
||||
async function sha256Hash(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const sources = await getSources();
|
||||
for (const source of sources) {
|
||||
const identifier = (source.name === undefined || source.name === "")
|
||||
? source.url
|
||||
: source.name;
|
||||
|
||||
try {
|
||||
const result = await fetch(source.url);
|
||||
const icsData = await result.text();
|
||||
|
||||
const calendarParsed: VCalendar = parseIcsCalendar(icsData);
|
||||
if (calendarParsed.events === undefined) {
|
||||
throw new Error("Didn't parse events from ics data");
|
||||
}
|
||||
|
||||
// The order here is the default order of columns without the select clause
|
||||
for (const icsEvent of calendarParsed.events) {
|
||||
events.push({
|
||||
summary: icsEvent.summary,
|
||||
sourceName: source.name,
|
||||
|
||||
location: icsEvent.location,
|
||||
description: icsEvent.description,
|
||||
|
||||
start: localDateString(icsEvent.start.date),
|
||||
end: icsEvent.end ? localDateString(icsEvent.end.date) : undefined,
|
||||
created: icsEvent.created
|
||||
? localDateString(icsEvent.created.date)
|
||||
: undefined,
|
||||
lastModified: icsEvent.lastModified
|
||||
? localDateString(icsEvent.lastModified.date)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Getting events from ${identifier} failed with:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Recursively converts all Date objects and ISO date strings to strings
|
||||
* Handles nested objects like {date: Date, local: {date: Date, timezone: string}}
|
||||
*/
|
||||
function convertDatesToStrings<T>(obj: T): DateToString<T> {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj as DateToString<T>;
|
||||
}
|
||||
return applyQuery(query, events, {}, {});
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return localDateString(obj) as DateToString<T>;
|
||||
}
|
||||
if (isIcsDateObjects(obj) && obj.date instanceof Date) {
|
||||
return localDateString(obj.date) as DateToString<T>;
|
||||
}
|
||||
|
||||
if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
||||
return localDateString(new Date(obj)) as DateToString<T>;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => convertDatesToStrings(item)) as DateToString<T>;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
result[key] = convertDatesToStrings((obj as any)[key]);
|
||||
}
|
||||
}
|
||||
return result as DateToString<T>;
|
||||
}
|
||||
|
||||
return obj as DateToString<T>;
|
||||
}
|
||||
|
||||
async function getSources(): Promise<Source[]> {
|
||||
const config = await system.getSpaceConfig("icalendar", {});
|
||||
// ============================================================================
|
||||
// Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
if (!config.sources || !Array.isArray(config.sources)) {
|
||||
// The queries are running on server, probably because of that, can't use editor.flashNotification
|
||||
console.error("Configure icalendar.sources");
|
||||
/**
|
||||
* Retrieves and validates configured calendar sources
|
||||
*/
|
||||
async function getSources(): Promise<Source[]> {
|
||||
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
||||
|
||||
if (!plugConfig.sources || !Array.isArray(plugConfig.sources)) {
|
||||
console.error("[iCalendar] Invalid configuration:", { plugConfig });
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources = config.sources;
|
||||
|
||||
if (sources.length === 0) {
|
||||
console.error("Empty icalendar.sources");
|
||||
if (plugConfig.sources.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const validated: Source[] = [];
|
||||
for (const src of sources) {
|
||||
for (const src of plugConfig.sources) {
|
||||
if (typeof src.url !== "string") {
|
||||
console.error(
|
||||
`Invalid iCalendar source`,
|
||||
src,
|
||||
);
|
||||
console.error("[iCalendar] Invalid source (missing url):", src);
|
||||
continue;
|
||||
}
|
||||
validated.push({
|
||||
url: src.url,
|
||||
name: (typeof src.name === "string") ? src.name : undefined,
|
||||
name: typeof src.name === "string" ? src.name : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
// Copied from @silverbulletmd/silverbullet/lib/dates.ts which is not exported in the package
|
||||
export function localDateString(d: Date): string {
|
||||
return d.getFullYear() +
|
||||
"-" + String(d.getMonth() + 1).padStart(2, "0") +
|
||||
"-" + String(d.getDate()).padStart(2, "0") +
|
||||
"T" + String(d.getHours()).padStart(2, "0") +
|
||||
":" + String(d.getMinutes()).padStart(2, "0") +
|
||||
":" + String(d.getSeconds()).padStart(2, "0") +
|
||||
"." + String(d.getMilliseconds()).padStart(3, "0");
|
||||
// ============================================================================
|
||||
// Calendar Fetching & Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetches and parses events from a single calendar source
|
||||
*/
|
||||
async function fetchAndParseCalendar(source: Source): Promise<CalendarEvent[]> {
|
||||
const response = await fetch(source.url);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
console.error(`[iCalendar] HTTP error:`, { source, status: response.status, statusText: response.statusText });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const icsData = await response.text();
|
||||
const calendar: IcsCalendar = convertIcsCalendar(undefined, icsData);
|
||||
|
||||
if (!calendar.events || calendar.events.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await Promise.all(calendar.events.map(async (icsEvent: IcsEvent): Promise<CalendarEvent> => {
|
||||
// Create unique ref by start date with UID or summary (handles recurring events)
|
||||
const uniqueKey = `${icsEvent.start?.date || ''}${icsEvent.uid || icsEvent.summary || ''}`;
|
||||
const ref = await sha256Hash(uniqueKey);
|
||||
|
||||
return convertDatesToStrings({
|
||||
...icsEvent,
|
||||
|
||||
ref,
|
||||
tag: "ical-event" as const,
|
||||
sourceName: source.name,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export async function showVersion() {
|
||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`);
|
||||
// ============================================================================
|
||||
// Exported Commands
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Synchronizes calendar events from configured sources and indexes them
|
||||
*/
|
||||
export async function syncCalendars() {
|
||||
try {
|
||||
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
||||
const cacheDurationSeconds = plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS;
|
||||
const cacheDurationMs = cacheDurationSeconds * 1000;
|
||||
|
||||
const sources = await getSources();
|
||||
if (sources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSync = await clientStore.get(CACHE_KEY);
|
||||
const now = Date.now();
|
||||
|
||||
if (lastSync && (now - lastSync) < cacheDurationMs) {
|
||||
const ageSeconds = Math.round((now - lastSync) / 1000);
|
||||
console.log(`[iCalendar] Using cached data (${ageSeconds}s old)`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[iCalendar] Syncing ${sources.length} calendar source(s)...`);
|
||||
await editor.flashNotification("Syncing calendars...", "info");
|
||||
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (const source of sources) {
|
||||
const identifier = source.name || source.url;
|
||||
|
||||
try {
|
||||
const events = await fetchAndParseCalendar(source);
|
||||
allEvents.push(...events);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`[iCalendar] Failed to sync "${identifier}":`, err);
|
||||
await editor.flashNotification(
|
||||
`Failed to sync "${identifier}"`,
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await index.indexObjects("$icalendar", allEvents);
|
||||
await clientStore.set(CACHE_KEY, now);
|
||||
|
||||
const summary = `Synced ${allEvents.length} events from ${successCount}/${sources.length} source(s)`;
|
||||
console.log(`[iCalendar] ${summary}`);
|
||||
await editor.flashNotification(summary, "info");
|
||||
} catch (err) {
|
||||
console.error("[iCalendar] Sync failed:", err);
|
||||
await editor.flashNotification("Failed to sync calendars", "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a fresh sync by clearing cache and syncing calendars
|
||||
*/
|
||||
export async function forceSync() {
|
||||
await clientStore.del(CACHE_KEY);
|
||||
console.log("[iCalendar] Cache cleared, forcing fresh sync");
|
||||
await editor.flashNotification("Forcing fresh calendar sync...", "info");
|
||||
await syncCalendars();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all indexed calendar events and cache
|
||||
*/
|
||||
export async function clearCache() {
|
||||
if (!await editor.confirm(
|
||||
"Are you sure you want to clear all calendar events and cache? This will remove all indexed calendar data."
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = "$icalendar";
|
||||
console.log("[iCalendar] Clearing index for", fileName);
|
||||
|
||||
const indexKey = "idx";
|
||||
const pageKey = "ridx";
|
||||
const allKeys: any[] = [];
|
||||
|
||||
const pageKeys = await datastore.query({
|
||||
prefix: [pageKey, fileName],
|
||||
});
|
||||
|
||||
for (const { key } of pageKeys) {
|
||||
allKeys.push(key);
|
||||
allKeys.push([indexKey, ...key.slice(2), fileName]);
|
||||
}
|
||||
|
||||
if (allKeys.length > 0) {
|
||||
await datastore.batchDel(allKeys);
|
||||
console.log("[iCalendar] Deleted", allKeys.length, "entries");
|
||||
}
|
||||
|
||||
await clientStore.del(CACHE_KEY);
|
||||
|
||||
console.log("[iCalendar] Calendar index and cache cleared");
|
||||
await editor.flashNotification("Calendar index and cache cleared", "info");
|
||||
} catch (err) {
|
||||
console.error("[iCalendar] Failed to clear cache:", err);
|
||||
await editor.flashNotification(
|
||||
`Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the plugin version
|
||||
*/
|
||||
export async function showVersion() {
|
||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
|
||||
}
|
||||
|
||||
BIN
url-nextcloud.png
Normal file
BIN
url-nextcloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Reference in New Issue
Block a user