mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-12-31 00:34:05 -06:00
fix(dates): improve date string parsing
Use Luxon to parse date/datetime strings. This avoids the `Date.parse`'s inconsistency between date-only (assumed UTC) and datetime (assumed local timezone) strings. (closes #1615) It also allows the date string's timezone to be carried along with the DateTime object, producing more friendly and semantically-correct timestamps.
This commit is contained in:
parent
ff9e60a7fc
commit
9a967e6d0c
18
package-lock.json
generated
18
package-lock.json
generated
@ -30,6 +30,7 @@
|
|||||||
"is-absolute-url": "^4.0.1",
|
"is-absolute-url": "^4.0.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"lightningcss": "^1.28.2",
|
"lightningcss": "^1.28.2",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"mdast-util-find-and-replace": "^3.0.1",
|
"mdast-util-find-and-replace": "^3.0.1",
|
||||||
"mdast-util-to-hast": "^13.2.0",
|
"mdast-util-to-hast": "^13.2.0",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
@ -80,6 +81,7 @@
|
|||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/pretty-time": "^1.1.5",
|
"@types/pretty-time": "^1.1.5",
|
||||||
"@types/source-map-support": "^0.5.10",
|
"@types/source-map-support": "^0.5.10",
|
||||||
@ -1922,6 +1924,13 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
|
||||||
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="
|
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||||
|
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mathjax": {
|
"node_modules/@types/mathjax": {
|
||||||
"version": "0.0.40",
|
"version": "0.0.40",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mathjax/-/mathjax-0.0.40.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mathjax/-/mathjax-0.0.40.tgz",
|
||||||
@ -4604,6 +4613,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-table": {
|
"node_modules/markdown-table": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz",
|
||||||
|
|||||||
@ -56,6 +56,7 @@
|
|||||||
"is-absolute-url": "^4.0.1",
|
"is-absolute-url": "^4.0.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"lightningcss": "^1.28.2",
|
"lightningcss": "^1.28.2",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"mdast-util-find-and-replace": "^3.0.1",
|
"mdast-util-find-and-replace": "^3.0.1",
|
||||||
"mdast-util-to-hast": "^13.2.0",
|
"mdast-util-to-hast": "^13.2.0",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
@ -103,6 +104,7 @@
|
|||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/hast": "^3.0.4",
|
"@types/hast": "^3.0.4",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/pretty-time": "^1.1.5",
|
"@types/pretty-time": "^1.1.5",
|
||||||
"@types/source-map-support": "^0.5.10",
|
"@types/source-map-support": "^0.5.10",
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { options } from "./util/sourcemap"
|
|||||||
import { Mutex } from "async-mutex"
|
import { Mutex } from "async-mutex"
|
||||||
import DepGraph from "./depgraph"
|
import DepGraph from "./depgraph"
|
||||||
import { getStaticResourcesFromPlugins } from "./plugins"
|
import { getStaticResourcesFromPlugins } from "./plugins"
|
||||||
|
import { Settings as LuxonSettings } from "luxon"
|
||||||
|
|
||||||
type Dependencies = Record<string, DepGraph<FilePath> | null>
|
type Dependencies = Record<string, DepGraph<FilePath> | null>
|
||||||
|
|
||||||
@ -53,6 +54,8 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
|
|||||||
const perf = new PerfTimer()
|
const perf = new PerfTimer()
|
||||||
const output = argv.output
|
const output = argv.output
|
||||||
|
|
||||||
|
LuxonSettings.defaultLocale = cfg.configuration.locale
|
||||||
|
|
||||||
const pluginCount = Object.values(cfg.plugins).flat().length
|
const pluginCount = Object.values(cfg.plugins).flat().length
|
||||||
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
|
const pluginNames = (key: "transformers" | "filters" | "emitters") =>
|
||||||
cfg.plugins[key].map((plugin) => plugin.name)
|
cfg.plugins[key].map((plugin) => plugin.name)
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
|
import { DateTime } from "luxon"
|
||||||
import { GlobalConfiguration } from "../cfg"
|
import { GlobalConfiguration } from "../cfg"
|
||||||
import { ValidLocale } from "../i18n"
|
import { ValidLocale } from "../i18n"
|
||||||
import { QuartzPluginData } from "../plugins/vfile"
|
import { QuartzPluginData } from "../plugins/vfile"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
date: Date
|
date: DateTime
|
||||||
locale?: ValidLocale
|
locale?: ValidLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
|
||||||
|
|
||||||
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
|
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): DateTime | undefined {
|
||||||
if (!cfg.defaultDateType) {
|
if (!cfg.defaultDateType) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
`Field 'defaultDateType' was not set in the configuration object of quartz.config.ts. See https://quartz.jzhao.xyz/configuration#general-configuration for more details.`,
|
||||||
@ -18,14 +19,17 @@ export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date
|
|||||||
return data.dates?.[cfg.defaultDateType]
|
return data.dates?.[cfg.defaultDateType]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
|
export function formatDate(d: DateTime, locale: ValidLocale = "en-US"): string {
|
||||||
return d.toLocaleDateString(locale, {
|
return d.toLocaleString(
|
||||||
year: "numeric",
|
{
|
||||||
month: "short",
|
year: "numeric",
|
||||||
day: "2-digit",
|
month: "short",
|
||||||
})
|
day: "2-digit",
|
||||||
|
},
|
||||||
|
{ locale: locale },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Date({ date, locale }: Props) {
|
export function Date({ date, locale }: Props) {
|
||||||
return <time datetime={date.toISOString()}>{formatDate(date, locale)}</time>
|
return <time datetime={date.toISO() || ""}>{formatDate(date, locale)}</time>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export function byDateAndAlphabetical(cfg: GlobalConfiguration): SortFn {
|
|||||||
return (f1, f2) => {
|
return (f1, f2) => {
|
||||||
if (f1.dates && f2.dates) {
|
if (f1.dates && f2.dates) {
|
||||||
// sort descending
|
// sort descending
|
||||||
return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
|
return getDate(cfg, f2)!.toMillis() - getDate(cfg, f1)!.toMillis()
|
||||||
} else if (f1.dates && !f2.dates) {
|
} else if (f1.dates && !f2.dates) {
|
||||||
// prioritize files with dates
|
// prioritize files with dates
|
||||||
return -1
|
return -1
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Root } from "hast"
|
import { Root } from "hast"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
import { GlobalConfiguration } from "../../cfg"
|
import { GlobalConfiguration } from "../../cfg"
|
||||||
import { getDate } from "../../components/Date"
|
import { getDate } from "../../components/Date"
|
||||||
import { escapeHTML } from "../../util/escape"
|
import { escapeHTML } from "../../util/escape"
|
||||||
@ -16,7 +17,7 @@ export type ContentDetails = {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
content: string
|
content: string
|
||||||
richContent?: string
|
richContent?: string
|
||||||
date?: Date
|
date?: DateTime
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +41,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
|||||||
const base = cfg.baseUrl ?? ""
|
const base = cfg.baseUrl ?? ""
|
||||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||||
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||||
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
|
${content.date && `<lastmod>${content.date.toISO()}</lastmod>`}
|
||||||
</url>`
|
</url>`
|
||||||
const urls = Array.from(idx)
|
const urls = Array.from(idx)
|
||||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||||
@ -56,13 +57,12 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
|
|||||||
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
<link>https://${joinSegments(base, encodeURI(slug))}</link>
|
||||||
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
|
||||||
<description>${content.richContent ?? content.description}</description>
|
<description>${content.richContent ?? content.description}</description>
|
||||||
<pubDate>${content.date?.toUTCString()}</pubDate>
|
<pubDate>${content.date?.toRFC2822()}</pubDate>
|
||||||
</item>`
|
</item>`
|
||||||
|
|
||||||
const items = Array.from(idx)
|
const items = Array.from(idx)
|
||||||
.sort(([_, f1], [__, f2]) => {
|
.sort(([_, f1], [__, f2]) => {
|
||||||
if (f1.date && f2.date) {
|
if (f1.date && f2.date) {
|
||||||
return f2.date.getTime() - f1.date.getTime()
|
return f2.date.toMillis() - f1.date.toMillis()
|
||||||
} else if (f1.date && !f2.date) {
|
} else if (f1.date && !f2.date) {
|
||||||
return -1
|
return -1
|
||||||
} else if (!f1.date && f2.date) {
|
} else if (!f1.date && f2.date) {
|
||||||
@ -119,7 +119,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
|||||||
const linkIndex: ContentIndex = new Map()
|
const linkIndex: ContentIndex = new Map()
|
||||||
for (const [tree, file] of content) {
|
for (const [tree, file] of content) {
|
||||||
const slug = file.data.slug!
|
const slug = file.data.slug!
|
||||||
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
|
const date = getDate(ctx.cfg.configuration, file.data) ?? DateTime.now()
|
||||||
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
|
||||||
linkIndex.set(slug, {
|
linkIndex.set(slug, {
|
||||||
title: file.data.frontmatter?.title!,
|
title: file.data.frontmatter?.title!,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { DateTime, DateTimeOptions } from "luxon"
|
||||||
import { Repository } from "@napi-rs/simple-git"
|
import { Repository } from "@napi-rs/simple-git"
|
||||||
import { QuartzTransformerPlugin } from "../types"
|
import { QuartzTransformerPlugin } from "../types"
|
||||||
import chalk from "chalk"
|
import chalk from "chalk"
|
||||||
@ -12,23 +13,51 @@ const defaultOptions: Options = {
|
|||||||
priority: ["frontmatter", "git", "filesystem"],
|
priority: ["frontmatter", "git", "filesystem"],
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceDate(fp: string, d: any): Date {
|
function parseDateString(
|
||||||
const dt = new Date(d)
|
fp: string,
|
||||||
const invalidDate = isNaN(dt.getTime()) || dt.getTime() === 0
|
d?: string | number | unknown,
|
||||||
if (invalidDate && d !== undefined) {
|
opts?: DateTimeOptions,
|
||||||
|
): DateTime<true> | undefined {
|
||||||
|
if (d == null) return
|
||||||
|
// handle cases where frontmatter property is a number (e.g. YYYYMMDD or even just YYYY)
|
||||||
|
if (typeof d === "number") d = d.toString()
|
||||||
|
if (typeof d !== "string") {
|
||||||
|
console.log(
|
||||||
|
chalk.yellow(`\nWarning: unexpected type (${typeof d}) for date "${d}" in \`${fp}\`.`),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = [
|
||||||
|
// ISO 8601 format, e.g. "2024-09-09T00:00:00[Africa/Algiers]", "2024-09-09T00:00+01:00", "2024-09-09"
|
||||||
|
DateTime.fromISO,
|
||||||
|
// RFC 2822 (used in email & RSS) format, e.g. "Mon, 09 Sep 2024 00:00:00 +0100"
|
||||||
|
DateTime.fromRFC2822,
|
||||||
|
// Luxon is stricter about the format of the datetime string than `Date`
|
||||||
|
// fallback to `Date` constructor iff Luxon fails to parse datetime
|
||||||
|
(s: string, o: DateTimeOptions) => DateTime.fromJSDate(new Date(s), o),
|
||||||
|
]
|
||||||
|
.values()
|
||||||
|
.map((f) => f(d, opts))
|
||||||
|
// find the first valid parse result
|
||||||
|
.find((dt) => dt != null && dt.isValid)
|
||||||
|
|
||||||
|
if (dt == null) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
`\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
|
`\nWarning: found invalid date "${d}" in \`${fp}\`. Supported formats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
return dt
|
||||||
return invalidDate ? new Date() : dt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MaybeDate = undefined | string | number
|
|
||||||
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (userOpts) => {
|
||||||
const opts = { ...defaultOptions, ...userOpts }
|
const opts = { ...defaultOptions, ...userOpts }
|
||||||
|
const parseOpts = {
|
||||||
|
setZone: true,
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
name: "CreatedModifiedDate",
|
name: "CreatedModifiedDate",
|
||||||
markdownPlugins() {
|
markdownPlugins() {
|
||||||
@ -36,23 +65,23 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
() => {
|
() => {
|
||||||
let repo: Repository | undefined = undefined
|
let repo: Repository | undefined = undefined
|
||||||
return async (_tree, file) => {
|
return async (_tree, file) => {
|
||||||
let created: MaybeDate = undefined
|
let created: DateTime | undefined = undefined
|
||||||
let modified: MaybeDate = undefined
|
let modified: DateTime | undefined = undefined
|
||||||
let published: MaybeDate = undefined
|
let published: DateTime | undefined = undefined
|
||||||
|
|
||||||
const fp = file.data.filePath!
|
const fp = file.data.filePath!
|
||||||
const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
|
const fullFp = path.isAbsolute(fp) ? fp : path.posix.join(file.cwd, fp)
|
||||||
for (const source of opts.priority) {
|
for (const source of opts.priority) {
|
||||||
if (source === "filesystem") {
|
if (source === "filesystem") {
|
||||||
const st = await fs.promises.stat(fullFp)
|
const st = await fs.promises.stat(fullFp)
|
||||||
created ||= st.birthtimeMs
|
created ||= DateTime.fromMillis(st.birthtimeMs)
|
||||||
modified ||= st.mtimeMs
|
modified ||= DateTime.fromMillis(st.mtimeMs)
|
||||||
} else if (source === "frontmatter" && file.data.frontmatter) {
|
} else if (source === "frontmatter" && file.data.frontmatter) {
|
||||||
created ||= file.data.frontmatter.date as MaybeDate
|
created ||= parseDateString(fp, file.data.frontmatter.date, parseOpts)
|
||||||
modified ||= file.data.frontmatter.lastmod as MaybeDate
|
modified ||= parseDateString(fp, file.data.frontmatter.lastmod, parseOpts)
|
||||||
modified ||= file.data.frontmatter.updated as MaybeDate
|
modified ||= parseDateString(fp, file.data.frontmatter.updated, parseOpts)
|
||||||
modified ||= file.data.frontmatter["last-modified"] as MaybeDate
|
modified ||= parseDateString(fp, file.data.frontmatter["last-modified"], parseOpts)
|
||||||
published ||= file.data.frontmatter.publishDate as MaybeDate
|
published ||= parseDateString(fp, file.data.frontmatter.publishDate, parseOpts)
|
||||||
} else if (source === "git") {
|
} else if (source === "git") {
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
// Get a reference to the main git repo.
|
// Get a reference to the main git repo.
|
||||||
@ -62,7 +91,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
|
modified ||= DateTime.fromMillis(
|
||||||
|
await repo.getFileLatestModifiedDateAsync(file.data.filePath!),
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
@ -75,9 +106,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
}
|
}
|
||||||
|
|
||||||
file.data.dates = {
|
file.data.dates = {
|
||||||
created: coerceDate(fp, created),
|
created: created ?? DateTime.now(),
|
||||||
modified: coerceDate(fp, modified),
|
modified: modified ?? DateTime.now(),
|
||||||
published: coerceDate(fp, published),
|
published: published ?? DateTime.now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -89,9 +120,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options>> = (u
|
|||||||
declare module "vfile" {
|
declare module "vfile" {
|
||||||
interface DataMap {
|
interface DataMap {
|
||||||
dates: {
|
dates: {
|
||||||
created: Date
|
created: DateTime
|
||||||
modified: Date
|
modified: DateTime
|
||||||
published: Date
|
published: DateTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user