このコミットが含まれているのは:
2026-07-18 14:55:11 +09:00
コミット a5ae7c6f2d
14個のファイルの変更513行の追加39行の削除
+4
ファイルの表示
@@ -131,6 +131,7 @@ class PostsController < ApplicationController
title: nil, title: nil,
thumbnail_base: nil, thumbnail_base: nil,
tags: nil, tags: nil,
display_tags: [],
original_created_from: nil, original_created_from: nil,
original_created_before: nil, original_created_before: nil,
duration: nil, duration: nil,
@@ -153,6 +154,7 @@ class PostsController < ApplicationController
title: metadata[:title], title: metadata[:title],
thumbnail_base: metadata[:thumbnail_base], thumbnail_base: metadata[:thumbnail_base],
tags: metadata[:tags], tags: metadata[:tags],
display_tags: metadata[:display_tags],
original_created_from: metadata[:original_created_from], original_created_from: metadata[:original_created_from],
original_created_before: metadata[:original_created_before], original_created_before: metadata[:original_created_before],
duration: metadata[:duration], duration: metadata[:duration],
@@ -173,6 +175,7 @@ class PostsController < ApplicationController
title: nil, title: nil,
thumbnail_base: nil, thumbnail_base: nil,
tags: nil, tags: nil,
display_tags: [],
original_created_from: nil, original_created_from: nil,
original_created_before: nil, original_created_before: nil,
duration: nil, duration: nil,
@@ -633,6 +636,7 @@ class PostsController < ApplicationController
:title, :title,
:thumbnail_base, :thumbnail_base,
:tags, :tags,
:display_tags,
:parent_post_ids, :parent_post_ids,
:original_created_from, :original_created_from,
:original_created_before, :original_created_before,
+8 -15
ファイルの表示
@@ -7,20 +7,13 @@ module PostCompactRepr
def base post def base post
return nil if post.nil? return nil if post.nil?
{ PostRepr
id: post.id, .common(post)
title: post.title, .slice(
url: post.url, 'id',
thumbnail_url: thumbnail_url(post) } 'title',
end 'url',
'thumbnail',
def thumbnail_url post 'thumbnail_base')
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
post.thumbnail,
only_path: false)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError
nil
end end
end end
+10
ファイルの表示
@@ -27,6 +27,7 @@ class PostCreatePlan
original_created_from: @attributes[:original_created_from].presence, original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence, original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections), tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s, duration: @attributes[:duration].to_s,
video_ms: video_ms, video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '), parent_post_ids: parent_post_ids.join(' '),
@@ -204,6 +205,15 @@ class PostCreatePlan
}.sort.join(' ') }.sort.join(' ')
end end
def display_tags direct_tag_specs, tag_sections
direct_tag_specs.map { |spec|
{
name: spec[:name],
category: spec[:category].to_s,
section_literals: tag_sections[spec[:name]].to_a.map { Post.section_literal(_1) } }
}.sort_by { _1[:name] }
end
def normalise_video_ms snapshot_tag_specs def normalise_video_ms snapshot_tag_specs
return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME } return nil unless snapshot_tag_specs.any? { _1[:name] == VIDEO_TAG_NAME }
+2
ファイルの表示
@@ -29,6 +29,7 @@ class PostCreatePreflight
original_created_before: preview[:attributes]['original_created_before'], original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'], duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'], video_ms: preview[:attributes]['video_ms'],
display_tags: preview[:display_tags] || [],
field_warnings: final_field_warnings(preview[:field_warnings] || { }), field_warnings: final_field_warnings(preview[:field_warnings] || { }),
base_warnings: preview[:base_warnings], base_warnings: preview[:base_warnings],
existing_post_id: preview[:existing_post_id], existing_post_id: preview[:existing_post_id],
@@ -63,6 +64,7 @@ class PostCreatePreflight
original_created_before: plan[:original_created_before], original_created_before: plan[:original_created_before],
duration: plan[:duration], duration: plan[:duration],
video_ms: plan[:video_ms], video_ms: plan[:video_ms],
display_tags: plan[:display_tags],
direct_tag_specs: plan[:direct_tag_specs], direct_tag_specs: plan[:direct_tag_specs],
default_tag_specs: plan[:default_tag_specs], default_tag_specs: plan[:default_tag_specs],
snapshot_tag_specs: plan[:snapshot_tag_specs], snapshot_tag_specs: plan[:snapshot_tag_specs],
+20
ファイルの表示
@@ -32,6 +32,7 @@ class PostMetadataFetcher
original_created_from: serialise_time(created_range&.first), original_created_from: serialise_time(created_range&.first),
original_created_before: serialise_time(created_range&.last), original_created_before: serialise_time(created_range&.last),
duration: serialise_duration(duration), duration: serialise_duration(duration),
display_tags: display_tags(platform_tags),
tags: platform_tags.join(' ') } tags: platform_tags.join(' ') }
end end
@@ -42,6 +43,24 @@ class PostMetadataFetcher
[] []
end end
def self.display_tags names
return [] if names.blank?
existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: names })
.index_by(&:name)
names.map { |name|
tag = existing_tags[name]
{
name: name,
category: (tag&.category || 'meta'),
section_literals: [] }
}
end
def self.original_created_range value def self.original_created_range value
return nil if value.blank? return nil if value.blank?
@@ -168,6 +187,7 @@ class PostMetadataFetcher
end end
private_class_method :platform_tags, private_class_method :platform_tags,
:display_tags,
:original_created_range, :original_created_range,
:parse_timestamp_range, :parse_timestamp_range,
:parse_nanoseconds, :parse_nanoseconds,
+39 -9
ファイルの表示
@@ -4,9 +4,13 @@ import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react' import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types' import type { Category, Tag } from '@/types'
type CommonProps = { type LightweightTag = {
name: string
category: Category }
type FullCommonProps = {
tag: Tag tag: Tag
nestLevel?: number nestLevel?: number
truncateOnMobile?: boolean truncateOnMobile?: boolean
@@ -14,18 +18,43 @@ type CommonProps = {
withCount?: boolean } withCount?: boolean }
type PropsWithLink = type PropsWithLink =
& CommonProps & FullCommonProps
& { linkFlg?: true } & { linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>> & Partial<ComponentProps<typeof PrefetchLink>>
type PropsWithoutLink = type PropsWithoutLink =
& CommonProps & FullCommonProps
& { linkFlg: false } & { linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>> & Partial<HTMLAttributes<HTMLSpanElement>>
type LightweightPropsWithLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg?: true }
& Partial<ComponentProps<typeof PrefetchLink>>
type LightweightPropsWithoutLink =
& {
tag: LightweightTag
nestLevel?: number
truncateOnMobile?: boolean
withWiki: false
withCount: false
linkFlg: false }
& Partial<HTMLAttributes<HTMLSpanElement>>
type Props = type Props =
| PropsWithLink | PropsWithLink
| PropsWithoutLink | PropsWithoutLink
| LightweightPropsWithLink
| LightweightPropsWithoutLink
const isFullTag = (tag: Tag | LightweightTag): tag is Tag =>
'id' in tag
const TagLink: FC<Props> = ({ tag, const TagLink: FC<Props> = ({ tag,
@@ -50,12 +79,13 @@ const TagLink: FC<Props> = ({ tag,
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline' 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
const markerWrapClass = 'shrink-0 self-start md:self-auto' const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto' const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
const textTitle = title const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`) ?? (matchedAlias == null ? tag.name : `${ matchedAlias }${ tag.name }`)
return ( return (
<span className={rootClass}> <span className={rootClass}>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki && isFullTag (tag)) && (
<span className={markerWrapClass}> <span className={markerWrapClass}>
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
@@ -118,7 +148,7 @@ const TagLink: FC<Props> = ({ tag,
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)} </span>)}
{tag.matchedAlias != null && ( {matchedAlias != null && (
<> <>
<span <span
title={textTitle} title={textTitle}
@@ -126,7 +156,7 @@ const TagLink: FC<Props> = ({ tag,
style={colourStyle} style={colourStyle}
{...props}> {...props}>
<ResponsiveMarqueeText <ResponsiveMarqueeText
text={tag.matchedAlias} text={matchedAlias}
title={textTitle} title={textTitle}
truncateOnMobile={truncateOnMobile}/> truncateOnMobile={truncateOnMobile}/>
</span> </span>
@@ -156,7 +186,7 @@ const TagLink: FC<Props> = ({ tag,
title={textTitle} title={textTitle}
truncateOnMobile={truncateOnMobile}/> truncateOnMobile={truncateOnMobile}/>
</span>)} </span>)}
{withCount && ( {(withCount && isFullTag (tag)) && (
<span className={countClass}>{tag.postCount}</span>)} <span className={countClass}>{tag.postCount}</span>)}
</span>) </span>)
} }
+3 -8
ファイルの表示
@@ -1,4 +1,5 @@
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
@@ -90,10 +91,7 @@ const PostImportRowSummary: FC<Props> = (
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300"> <div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url} {row.url}
</div> </div>
{String (row.attributes.tags ?? '') && ( <PostImportTagLinks tags={row.displayTags}/>
<div className="truncate text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '')}
</div>)}
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
</div> </div>
@@ -153,10 +151,7 @@ const PostImportRowSummary: FC<Props> = (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>} {displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
</div> </div>
{String (row.attributes.tags ?? '') && ( <PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{String (row.attributes.tags ?? '')}
</div>)}
<div className="text-xs text-neutral-500 dark:text-neutral-400"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)} {summaryDate (row)}
</div> </div>
+35
ファイルの表示
@@ -0,0 +1,35 @@
import TagLink from '@/components/TagLink'
import type { FC } from 'react'
import type { PostImportDisplayTag } from '@/lib/postImportTypes'
type Props = {
tags: PostImportDisplayTag[] | undefined }
const PostImportTagLinks: FC<Props> = ({ tags }) => {
if (tags == null || tags.length === 0)
return null
return (
<div className="flex flex-wrap gap-2">
{tags.map (tag => {
const key = `${ tag.category }:${ tag.name }:${ tag.sectionLiterals?.join ('|') ?? '' }`
return (
<span key={key} className="inline-flex flex-nowrap items-baseline gap-1">
<TagLink
tag={{
name: tag.name,
category: tag.category }}
withWiki={false}
withCount={false}/>
{tag.sectionLiterals?.map (literal => (
<span key={literal} className="text-xs text-neutral-500 dark:text-neutral-400">
{literal}
</span>))}
</span>)
})}
</div>)
}
export default PostImportTagLinks
+15
ファイルの表示
@@ -68,6 +68,10 @@ export const validatableImportRows = (rows: PostImportRow[]): PostImportRow[] =>
const buildResetSnapshot = (row: PostImportRow) => ({ const buildResetSnapshot = (row: PostImportRow) => ({
url: row.url, url: row.url,
attributes: { ...row.attributes }, attributes: { ...row.attributes },
displayTags: row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [],
provenance: { ...row.provenance }, provenance: { ...row.provenance },
tagSources: { tagSources: {
automatic: row.tagSources?.automatic ?? '', automatic: row.tagSources?.automatic ?? '',
@@ -243,6 +247,11 @@ export const buildNextEditedRow = (
...editingRow, ...editingRow,
url: draft.url, url: draft.url,
attributes: nextAttributes, attributes: nextAttributes,
displayTags:
editingRow.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
thumbnailFile: draft.thumbnailFile, thumbnailFile: draft.thumbnailFile,
provenance: { provenance: {
...nextProvenance, ...nextProvenance,
@@ -316,6 +325,12 @@ export const mergeValidatedImportRows = (
...previous, ...previous,
url: row.url, url: row.url,
attributes: row.attributes, attributes: row.attributes,
displayTags:
row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
provenance: row.provenance, provenance: row.provenance,
tagSources: row.tagSources, tagSources: row.tagSources,
skipReason: row.skipReason, skipReason: row.skipReason,
+92 -3
ファイルの表示
@@ -1,4 +1,5 @@
import type { PostImportOrigin, import type { PostImportOrigin,
PostImportDisplayTag,
PostImportExistingPost, PostImportExistingPost,
PostImportResetSnapshot, PostImportResetSnapshot,
PostImportRow, PostImportRow,
@@ -43,6 +44,7 @@ const ROW_KEYS = [
'existingPostId', 'existingPostId',
'existingPost', 'existingPost',
'metadataUrl', 'metadataUrl',
'displayTags',
'resetSnapshot', 'resetSnapshot',
'createdPostId', 'createdPostId',
'importStatus', 'importStatus',
@@ -216,11 +218,53 @@ const hasOnlyKeys = (
): boolean => ): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key)) Object.keys (value).every (key => allowedKeys.includes (key))
const sanitiseDisplayTag = (value: unknown): PostImportDisplayTag | null => {
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, ['name', 'category', 'sectionLiterals'])))
return null
if (typeof value.name !== 'string' || value.name === '')
return null
if (typeof value.category !== 'string')
return null
if (!(['deerjikist',
'meme',
'character',
'general',
'material',
'meta',
'nico'] as const).includes (value.category as never))
return null
if (value.sectionLiterals != null
&& (!(Array.isArray (value.sectionLiterals))
|| !(value.sectionLiterals.every (entry => typeof entry === 'string'))))
return null
return {
name: value.name,
category: value.category,
sectionLiterals:
value.sectionLiterals == null
? undefined
: [...value.sectionLiterals] }
}
const sanitiseDisplayTags = (value: unknown): PostImportDisplayTag[] | null => {
if (value == null)
return []
if (!(Array.isArray (value)))
return null
const tags = value.map (sanitiseDisplayTag)
return tags.some (tag => tag == null) ? null : (tags as PostImportDisplayTag[])
}
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => { const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
if (!(isPlainObject (value))) if (!(isPlainObject (value)))
return null return null
if (!(hasOnlyKeys (value, ['url', if (!(hasOnlyKeys (value, ['url',
'attributes', 'attributes',
'displayTags',
'provenance', 'provenance',
'tagSources', 'tagSources',
'fieldWarnings', 'fieldWarnings',
@@ -258,10 +302,14 @@ const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null =
return null return null
if (value.metadataUrl != null && typeof value.metadataUrl !== 'string') if (value.metadataUrl != null && typeof value.metadataUrl !== 'string')
return null return null
const displayTags = sanitiseDisplayTags (value.displayTags)
if (displayTags == null)
return null
return { return {
url: value.url, url: value.url,
attributes: value.attributes as Record<string, string | number>, attributes: value.attributes as Record<string, string | number>,
displayTags,
provenance: value.provenance as Record<string, PostImportOrigin>, provenance: value.provenance as Record<string, PostImportOrigin>,
tagSources: value.tagSources as Record<PostImportOrigin, string>, tagSources: value.tagSources as Record<PostImportOrigin, string>,
fieldWarnings, fieldWarnings,
@@ -278,14 +326,39 @@ const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefine
return undefined return undefined
if (typeof value.title !== 'string' || typeof value.url !== 'string') if (typeof value.title !== 'string' || typeof value.url !== 'string')
return undefined return undefined
if (value.thumbnailUrl != null && typeof value.thumbnailUrl !== 'string') if (value.thumbnail !== undefined
&& value.thumbnail !== null
&& typeof value.thumbnail !== 'string')
return undefined
if (value.thumbnailBase !== undefined
&& value.thumbnailBase !== null
&& typeof value.thumbnailBase !== 'string')
return undefined
if (value.thumbnailUrl !== undefined
&& value.thumbnailUrl !== null
&& typeof value.thumbnailUrl !== 'string')
return undefined return undefined
return { return {
id: Number (value.id), id: Number (value.id),
title: value.title, title: value.title,
url: value.url, url: value.url,
thumbnailUrl: value.thumbnailUrl as string | undefined } thumbnail:
value.thumbnail === null
? null
: typeof value.thumbnail === 'string'
? value.thumbnail
: value.thumbnailUrl === null
? null
: typeof value.thumbnailUrl === 'string'
? value.thumbnailUrl
: undefined,
thumbnailBase:
value.thumbnailBase === null
? null
: typeof value.thumbnailBase === 'string'
? value.thumbnailBase
: undefined }
} }
const canonicalisePersistedRow = ( const canonicalisePersistedRow = (
@@ -319,12 +392,18 @@ const serialiseExistingPost = (value: PostImportExistingPost | undefined) =>
id: value.id, id: value.id,
title: value.title, title: value.title,
url: value.url, url: value.url,
thumbnailUrl: value.thumbnailUrl } thumbnail: value.thumbnail,
thumbnailBase: value.thumbnailBase }
const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({ const serialiseResetSnapshot = (value: PostImportResetSnapshot) => ({
url: value.url, url: value.url,
attributes: Object.fromEntries ( attributes: Object.fromEntries (
Object.entries (value.attributes).map (([key, entry]) => [key, entry])), Object.entries (value.attributes).map (([key, entry]) => [key, entry])),
displayTags: value.displayTags.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })),
provenance: Object.fromEntries ( provenance: Object.fromEntries (
Object.entries (value.provenance).map (([key, entry]) => [key, entry])), Object.entries (value.provenance).map (([key, entry]) => [key, entry])),
tagSources: { tagSources: {
@@ -366,6 +445,12 @@ export const serialisePostImportRow = (row: PostImportRow) => {
Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])), Object.entries (row.importErrors).map (([key, entry]) => [key, [...entry]])),
provenance: Object.fromEntries ( provenance: Object.fromEntries (
Object.entries (row.provenance).map (([key, entry]) => [key, entry])), Object.entries (row.provenance).map (([key, entry]) => [key, entry])),
displayTags:
row.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [],
tagSources: tagSources:
row.tagSources == null row.tagSources == null
? undefined ? undefined
@@ -475,6 +560,9 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string'))) if (!(Object.values (value.tagSources).every (entry => typeof entry === 'string')))
return null return null
} }
const displayTags = sanitiseDisplayTags (value.displayTags)
if (displayTags == null)
return null
const existingPost = sanitiseExistingPost (value.existingPost) const existingPost = sanitiseExistingPost (value.existingPost)
const existingPostId = const existingPostId =
@@ -496,6 +584,7 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
? importErrors ? importErrors
: undefined, : undefined,
provenance: value.provenance as Record<string, PostImportOrigin>, provenance: value.provenance as Record<string, PostImportOrigin>,
displayTags,
tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined, tagSources: value.tagSources as Record<PostImportOrigin, string> | undefined,
status: value.status, status: value.status,
skipReason: value.skipReason ?? undefined, skipReason: value.skipReason ?? undefined,
+11 -1
ファイルの表示
@@ -1,3 +1,5 @@
import type { Category } from '@/types'
export type PostImportOrigin = 'automatic' | 'manual' export type PostImportOrigin = 'automatic' | 'manual'
export type PostImportRepairMode = 'all' | 'failed' export type PostImportRepairMode = 'all' | 'failed'
export type PostImportStatus = export type PostImportStatus =
@@ -9,9 +11,15 @@ export type PostImportSkipReason = 'existing' | 'manual'
export type PostImportResultStatus = 'created' | 'skipped' | 'failed' export type PostImportResultStatus = 'created' | 'skipped' | 'failed'
export type PostImportAttributeValue = string | number export type PostImportAttributeValue = string | number
export type PostImportDisplayTag = {
name: string
category: Category
sectionLiterals?: string[] }
export type PostImportResetSnapshot = { export type PostImportResetSnapshot = {
url: string url: string
attributes: Record<string, PostImportAttributeValue> attributes: Record<string, PostImportAttributeValue>
displayTags: PostImportDisplayTag[]
provenance: Record<string, PostImportOrigin> provenance: Record<string, PostImportOrigin>
tagSources: Record<PostImportOrigin, string> tagSources: Record<PostImportOrigin, string>
fieldWarnings: Record<string, string[]> fieldWarnings: Record<string, string[]>
@@ -22,7 +30,8 @@ export type PostImportExistingPost = {
id: number id: number
title: string title: string
url: string url: string
thumbnailUrl?: string } thumbnail?: string | null
thumbnailBase?: string | null }
export type PostImportRow = { export type PostImportRow = {
sourceRow: number sourceRow: number
@@ -39,6 +48,7 @@ export type PostImportRow = {
existingPostId?: number existingPostId?: number
existingPost?: PostImportExistingPost existingPost?: PostImportExistingPost
metadataUrl?: string metadataUrl?: string
displayTags?: PostImportDisplayTag[]
resetSnapshot: PostImportResetSnapshot resetSnapshot: PostImportResetSnapshot
createdPostId?: number createdPostId?: number
importStatus?: PostImportStatus importStatus?: PostImportStatus
+226
ファイルの表示
@@ -1,7 +1,9 @@
import { CATEGORIES } from '@/consts'
import { resultRepairMode } from '@/lib/postImportRows' import { resultRepairMode } from '@/lib/postImportRows'
import { validatePostImportSessionId } from '@/lib/postImportStorage' import { validatePostImportSessionId } from '@/lib/postImportStorage'
import type { import type {
PostImportDisplayTag,
PostImportOrigin, PostImportOrigin,
PostImportRepairMode, PostImportRepairMode,
PostImportRow, PostImportRow,
@@ -18,9 +20,11 @@ type QueryRowState = {
duration: string duration: string
tags: string tags: string
parent_post_ids: string parent_post_ids: string
display_tags: PostImportDisplayTag[]
manual: string[] manual: string[]
skip: PostImportSkipReason | null skip: PostImportSkipReason | null
existing_post_id: number | null existing_post_id: number | null
existing_post: PostImportRow['existingPost'] | null
import_status: PostImportRow['importStatus'] | null import_status: PostImportRow['importStatus'] | null
created_post_id: number | null created_post_id: number | null
recoverable: boolean | null } recoverable: boolean | null }
@@ -45,9 +49,11 @@ type EncodedRowState = {
d?: string d?: string
g?: string g?: string
p?: string p?: string
k?: EncodedDisplayTag[]
m?: string[] m?: string[]
s?: EncodedSkip s?: EncodedSkip
e?: number e?: number
o?: EncodedExistingPost
i?: EncodedImportStatus i?: EncodedImportStatus
c?: number c?: number
r?: boolean } r?: boolean }
@@ -56,6 +62,18 @@ type EncodedState = {
v: 1 v: 1
rows: EncodedRowState[] } rows: EncodedRowState[] }
type EncodedDisplayTag = {
n: string
c: PostImportDisplayTag['category']
s?: string[] }
type EncodedExistingPost = {
i: number
t: string
u: string
h?: string | null
b?: string | null }
export type ParsedPostNewState = { export type ParsedPostNewState = {
rows: PostImportRow[] rows: PostImportRow[]
source: string source: string
@@ -77,9 +95,11 @@ const BASIC_KEYS = [
'tags', 'tags',
'parent_post_ids'] as const 'parent_post_ids'] as const
const STATE_KEYS = [ const STATE_KEYS = [
'display_tags',
'manual', 'manual',
'skip', 'skip',
'existing_post_id', 'existing_post_id',
'existing_post',
'import_status', 'import_status',
'created_post_id', 'created_post_id',
'recoverable'] as const 'recoverable'] as const
@@ -95,9 +115,11 @@ const ENCODED_ROW_KEYS = [
'd', 'd',
'g', 'g',
'p', 'p',
'k',
'm', 'm',
's', 's',
'e', 'e',
'o',
'i', 'i',
'c', 'c',
'r'] as const 'r'] as const
@@ -200,6 +222,153 @@ const decodeImportStatus = (
return null return null
} }
const sanitiseDisplayTag = (value: unknown): PostImportDisplayTag | null => {
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, ['n', 'c', 's'])))
return null
if (typeof value.n !== 'string' || value.n === '')
return null
if (!(CATEGORIES.includes (value.c as never)))
return null
if (value.s != null && !(Array.isArray (value.s)) || !(value.s ?? []).every (entry => typeof entry === 'string'))
return null
return {
name: value.n,
category: value.c,
sectionLiterals: value.s == null ? undefined : [...value.s] }
}
const sanitiseDisplayTags = (
value: unknown,
): PostImportDisplayTag[] | null => {
if (value == null)
return []
if (!(Array.isArray (value)))
return null
const tags = value.map (sanitiseDisplayTag)
return tags.some (tag => tag == null) ? null : (tags as PostImportDisplayTag[])
}
const encodeDisplayTags = (
tags: PostImportDisplayTag[] | undefined,
): EncodedDisplayTag[] | undefined =>
tags == null || tags.length === 0
? undefined
: tags.map (tag => ({
n: tag.name,
c: tag.category,
s:
tag.sectionLiterals == null || tag.sectionLiterals.length === 0
? undefined
: [...tag.sectionLiterals] }))
const encodeDisplayTagsParam = (
tags: PostImportDisplayTag[] | undefined,
): string | null => {
const encodedTags = encodeDisplayTags (tags)
if (encodedTags == null)
return null
return encodeBase64UrlBytes (textEncoder.encode (JSON.stringify (encodedTags)))
}
const decodeDisplayTagsParam = (
value: string | null,
): PostImportDisplayTag[] | null => {
if (value == null || value === '')
return []
const bytes = decodeBase64UrlBytes (value)
if (bytes == null || bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES)
return null
try
{
return sanitiseDisplayTags (JSON.parse (textDecoder.decode (bytes)))
}
catch
{
return null
}
}
const sanitiseExistingPost = (
value: unknown,
): PostImportRow['existingPost'] | null => {
if (value == null)
return null
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, ['i', 't', 'u', 'h', 'b'])))
return null
if (!(typeof value.i === 'number'
&& Number.isSafeInteger (value.i)
&& value.i > 0))
return null
if (typeof value.t !== 'string' || typeof value.u !== 'string')
return null
if (value.h != null && typeof value.h !== 'string')
return null
if (value.b != null && typeof value.b !== 'string')
return null
return {
id: value.i,
title: value.t,
url: value.u,
thumbnail: value.h,
thumbnailBase: value.b }
}
const encodeExistingPost = (
post: PostImportRow['existingPost'] | undefined,
): EncodedExistingPost | undefined =>
post == null
? undefined
: {
i: post.id,
t: post.title,
u: post.url,
h: post.thumbnail,
b: post.thumbnailBase }
const encodeExistingPostParam = (
post: PostImportRow['existingPost'] | undefined,
): string | null => {
const encodedPost = encodeExistingPost (post)
if (encodedPost == null)
return null
return encodeBase64UrlBytes (textEncoder.encode (JSON.stringify (encodedPost)))
}
const decodeExistingPostParam = (
value: string | null,
): PostImportRow['existingPost'] | null => {
if (value == null || value === '')
return null
const bytes = decodeBase64UrlBytes (value)
if (bytes == null || bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES)
return null
try
{
return sanitiseExistingPost (JSON.parse (textDecoder.decode (bytes)))
}
catch
{
return null
}
}
const isStrictPositiveIntegerString = (value: string): boolean => const isStrictPositiveIntegerString = (value: string): boolean =>
/^[1-9][0-9]*$/.test (value) /^[1-9][0-9]*$/.test (value)
@@ -338,9 +507,11 @@ const baseRowState = (row: PostImportRow): QueryRowState => ({
duration: String (row.attributes.duration ?? ''), duration: String (row.attributes.duration ?? ''),
tags: String (row.attributes.tags ?? ''), tags: String (row.attributes.tags ?? ''),
parent_post_ids: String (row.attributes.parentPostIds ?? ''), parent_post_ids: String (row.attributes.parentPostIds ?? ''),
display_tags: row.displayTags ?? [],
manual: manualFields (row), manual: manualFields (row),
skip: row.skipReason ?? null, skip: row.skipReason ?? null,
existing_post_id: row.existingPostId ?? null, existing_post_id: row.existingPostId ?? null,
existing_post: row.existingPost ?? null,
import_status: row.importStatus ?? null, import_status: row.importStatus ?? null,
created_post_id: row.createdPostId ?? null, created_post_id: row.createdPostId ?? null,
recoverable: row.recoverable ?? null }) recoverable: row.recoverable ?? null })
@@ -379,13 +550,36 @@ const buildRow = (state: FullRowState): PostImportRow => {
baseWarnings: [], baseWarnings: [],
validationErrors: { }, validationErrors: { },
provenance, provenance,
displayTags: state.display_tags.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })),
tagSources, tagSources,
status: 'pending', status: 'pending',
skipReason: state.skip ?? undefined, skipReason: state.skip ?? undefined,
existingPostId: state.existing_post_id ?? undefined, existingPostId: state.existing_post_id ?? undefined,
existingPost:
state.existing_post == null
? undefined
: {
id: state.existing_post.id,
title: state.existing_post.title,
url: state.existing_post.url,
thumbnail: state.existing_post.thumbnail,
thumbnailBase: state.existing_post.thumbnailBase },
resetSnapshot: { resetSnapshot: {
url: state.url, url: state.url,
attributes: { ...attributes }, attributes: { ...attributes },
displayTags: state.display_tags.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })),
provenance: { ...provenance }, provenance: { ...provenance },
tagSources: { ...tagSources }, tagSources: { ...tagSources },
fieldWarnings: { }, fieldWarnings: { },
@@ -429,6 +623,8 @@ const parseEncodedRow = (
const createdPostId = row['c'] const createdPostId = row['c']
const recoverable = row['r'] const recoverable = row['r']
const sourceRow = row['n'] const sourceRow = row['n']
const displayTags = sanitiseDisplayTags (row['k'])
const existingPost = sanitiseExistingPost (row['o'])
if (requireUrl && typeof url !== 'string') if (requireUrl && typeof url !== 'string')
return null return null
@@ -454,6 +650,10 @@ const parseEncodedRow = (
return null return null
if (recoverable != null && typeof recoverable !== 'boolean') if (recoverable != null && typeof recoverable !== 'boolean')
return null return null
if (displayTags == null)
return null
if (row['o'] != null && existingPost == null)
return null
if (sourceRow != null if (sourceRow != null
&& !(typeof sourceRow === 'number' && !(typeof sourceRow === 'number'
&& Number.isSafeInteger (sourceRow) && Number.isSafeInteger (sourceRow)
@@ -461,6 +661,8 @@ const parseEncodedRow = (
return null return null
if (skip === 'existing' && existingPostId == null) if (skip === 'existing' && existingPostId == null)
return null return null
if (existingPost != null && existingPost.id !== existingPostId)
return null
if (importStatus === 'created' && createdPostId == null) if (importStatus === 'created' && createdPostId == null)
return null return null
if (importStatus !== 'created' && createdPostId != null) if (importStatus !== 'created' && createdPostId != null)
@@ -512,9 +714,11 @@ const parseEncodedRow = (
duration: parsedStrings.duration, duration: parsedStrings.duration,
tags: parsedStrings.tags, tags: parsedStrings.tags,
parent_post_ids: parsedStrings.parent_post_ids, parent_post_ids: parsedStrings.parent_post_ids,
display_tags: displayTags,
manual: manual ?? [], manual: manual ?? [],
skip, skip,
existing_post_id: existingPostId ?? null, existing_post_id: existingPostId ?? null,
existing_post: existingPost,
import_status: importStatus, import_status: importStatus,
created_post_id: createdPostId ?? null, created_post_id: createdPostId ?? null,
recoverable: recoverable ?? null } recoverable: recoverable ?? null }
@@ -543,9 +747,11 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
duration: '', duration: '',
tags: '', tags: '',
parent_post_ids: '', parent_post_ids: '',
display_tags: [],
manual: [], manual: [],
skip: null, skip: null,
existing_post_id: null, existing_post_id: null,
existing_post: null,
import_status: null, import_status: null,
created_post_id: null, created_post_id: null,
recoverable: null }) recoverable: null })
@@ -580,14 +786,22 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
return null return null
const existingPostIdValue = params.get ('existing_post_id') const existingPostIdValue = params.get ('existing_post_id')
const createdPostIdValue = params.get ('created_post_id') const createdPostIdValue = params.get ('created_post_id')
const displayTags = decodeDisplayTagsParam (params.get ('display_tags'))
const existingPost = decodeExistingPostParam (params.get ('existing_post'))
const existingPostId = parsePositiveInt (existingPostIdValue) const existingPostId = parsePositiveInt (existingPostIdValue)
const createdPostId = parsePositiveInt (createdPostIdValue) const createdPostId = parsePositiveInt (createdPostIdValue)
if (displayTags == null)
return null
if (params.get ('existing_post') != null && existingPost == null)
return null
if (existingPostIdValue != null && existingPostId == null) if (existingPostIdValue != null && existingPostId == null)
return null return null
if (createdPostIdValue != null && createdPostId == null) if (createdPostIdValue != null && createdPostId == null)
return null return null
if (skipValue === 'existing' && existingPostId == null) if (skipValue === 'existing' && existingPostId == null)
return null return null
if (existingPost != null && existingPost.id !== existingPostId)
return null
if (importStatus === 'created' && createdPostId == null) if (importStatus === 'created' && createdPostId == null)
return null return null
if (importStatus !== 'created' && createdPostIdValue != null) if (importStatus !== 'created' && createdPostIdValue != null)
@@ -608,9 +822,11 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
duration: params.get ('duration') ?? '', duration: params.get ('duration') ?? '',
tags: params.get ('tags') ?? '', tags: params.get ('tags') ?? '',
parent_post_ids: params.get ('parent_post_ids') ?? '', parent_post_ids: params.get ('parent_post_ids') ?? '',
display_tags: displayTags,
manual: parseManual (params.get ('manual')), manual: parseManual (params.get ('manual')),
skip: skipValue, skip: skipValue,
existing_post_id: existingPostId, existing_post_id: existingPostId,
existing_post: existingPost,
import_status: importStatus, import_status: importStatus,
created_post_id: createdPostId, created_post_id: createdPostId,
recoverable }) recoverable })
@@ -727,6 +943,12 @@ const regularQuery = (rows: PostImportRow[]): URLSearchParams => {
params.set ('duration', String (row?.attributes.duration ?? '')) params.set ('duration', String (row?.attributes.duration ?? ''))
params.set ('tags', String (row?.attributes.tags ?? '')) params.set ('tags', String (row?.attributes.tags ?? ''))
params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? '')) params.set ('parent_post_ids', String (row?.attributes.parentPostIds ?? ''))
const displayTags = encodeDisplayTagsParam (row?.displayTags)
if (displayTags != null)
params.set ('display_tags', displayTags)
const existingPost = encodeExistingPostParam (row?.existingPost)
if (existingPost != null)
params.set ('existing_post', existingPost)
const manual = manualFields (row).join (',') const manual = manualFields (row).join (',')
if (manual !== '') if (manual !== '')
params.set ('manual', manual) params.set ('manual', manual)
@@ -776,12 +998,16 @@ const encodeRowState = (
encoded.g = base.tags encoded.g = base.tags
if (base.parent_post_ids !== '') if (base.parent_post_ids !== '')
encoded.p = base.parent_post_ids encoded.p = base.parent_post_ids
if (base.display_tags.length > 0)
encoded.k = encodeDisplayTags (base.display_tags)
if (base.manual.length > 0) if (base.manual.length > 0)
encoded.m = [...base.manual] encoded.m = [...base.manual]
if (base.skip != null) if (base.skip != null)
encoded.s = encodeSkip (base.skip) encoded.s = encodeSkip (base.skip)
if (base.existing_post_id != null) if (base.existing_post_id != null)
encoded.e = base.existing_post_id encoded.e = base.existing_post_id
if (base.existing_post != null)
encoded.o = encodeExistingPost (base.existing_post)
if (base.import_status != null) if (base.import_status != null)
encoded.i = encodeImportStatus (base.import_status) encoded.i = encodeImportStatus (base.import_status)
if (base.created_post_id != null) if (base.created_post_id != null)
+46 -3
ファイルの表示
@@ -74,6 +74,7 @@ type PostMetadataResponse = {
duration?: string duration?: string
videoMs?: number videoMs?: number
tags?: string tags?: string
displayTags?: PostImportRow['displayTags']
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
validationErrors?: Record<string, string[]> validationErrors?: Record<string, string[]>
@@ -82,7 +83,8 @@ type PostMetadataResponse = {
id: number id: number
title: string title: string
url: string url: string
thumbnailUrl?: string } | null } thumbnail?: string | null
thumbnailBase?: string | null } | null }
type BulkApiRow = { type BulkApiRow = {
status: 'created' | 'skipped' | 'failed' status: 'created' | 'skipped' | 'failed'
@@ -92,7 +94,8 @@ type BulkApiRow = {
id: number id: number
title: string title: string
url: string url: string
thumbnailUrl?: string } | null thumbnail?: string | null
thumbnailBase?: string | null } | null
fieldWarnings?: Record<string, string[]> fieldWarnings?: Record<string, string[]>
baseWarnings?: string[] baseWarnings?: string[]
errors?: Record<string, string[]> errors?: Record<string, string[]>
@@ -238,6 +241,14 @@ const mergeDryRunRow = (
videoMs: result.videoMs ?? '', videoMs: result.videoMs ?? '',
tags: result.tags ?? '', tags: result.tags ?? '',
parentPostIds: String (result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') }, parentPostIds: String (result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
displayTags:
result.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })) ?? [],
fieldWarnings, fieldWarnings,
baseWarnings: result.baseWarnings ?? [], baseWarnings: result.baseWarnings ?? [],
validationErrors: { }, validationErrors: { },
@@ -273,6 +284,14 @@ const buildPreviewResetSnapshot = (
videoMs: preview.videoMs ?? '', videoMs: preview.videoMs ?? '',
tags: preview.tags ?? '', tags: preview.tags ?? '',
parentPostIds: String (preview.parentPostIds ?? '') }, parentPostIds: String (preview.parentPostIds ?? '') },
displayTags:
preview.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })) ?? [],
provenance: { provenance: {
url: 'manual', url: 'manual',
title: 'automatic', title: 'automatic',
@@ -368,6 +387,14 @@ const mergePreviewRow = (
...currentRow, ...currentRow,
attributes: { ...currentRow.attributes }, attributes: { ...currentRow.attributes },
provenance: { ...currentRow.provenance }, provenance: { ...currentRow.provenance },
displayTags:
currentRow.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })) ?? [],
tagSources: { tagSources: {
automatic: currentRow.tagSources?.automatic ?? '', automatic: currentRow.tagSources?.automatic ?? '',
manual: currentRow.tagSources?.manual ?? '' }, manual: currentRow.tagSources?.manual ?? '' },
@@ -406,6 +433,14 @@ const mergePreviewRow = (
if (currentRow.provenance.tags !== 'manual') if (currentRow.provenance.tags !== 'manual')
{ {
nextRow.attributes.tags = preview.tags ?? '' nextRow.attributes.tags = preview.tags ?? ''
nextRow.displayTags =
preview.displayTags?.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })) ?? []
} }
nextRow.tagSources.automatic = preview.tags ?? '' nextRow.tagSources.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual') if (currentRow.provenance.parentPostIds !== 'manual')
@@ -459,7 +494,7 @@ const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ id, rows }) => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 p-3"> className="flex items-center gap-3 p-3">
<PostThumbnailPreview <PostThumbnailPreview
url={post.thumbnailUrl ?? ''} url={post.thumbnail || post.thumbnailBase || ''}
className="h-10 w-10 shrink-0"/> className="h-10 w-10 shrink-0"/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium"> <div className="truncate text-sm font-medium">
@@ -809,6 +844,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
? { ...currentRow, ? { ...currentRow,
url: currentRow.resetSnapshot.url, url: currentRow.resetSnapshot.url,
attributes: { ...currentRow.resetSnapshot.attributes }, attributes: { ...currentRow.resetSnapshot.attributes },
displayTags:
currentRow.resetSnapshot.displayTags.map (tag => ({
name: tag.name,
category: tag.category,
sectionLiterals:
tag.sectionLiterals == null
? undefined
: [...tag.sectionLiterals] })),
provenance: { ...currentRow.resetSnapshot.provenance }, provenance: { ...currentRow.resetSnapshot.provenance },
tagSources: { ...currentRow.resetSnapshot.tagSources }, tagSources: { ...currentRow.resetSnapshot.tagSources },
fieldWarnings: Object.fromEntries ( fieldWarnings: Object.fromEntries (
+2
ファイルの表示
@@ -76,6 +76,7 @@ const buildInitialRows = (source: string): PostImportRow[] =>
automatic: '', automatic: '',
manual: '' }, manual: '' },
status: 'pending' as const, status: 'pending' as const,
displayTags: [],
resetSnapshot: { resetSnapshot: {
url: row.url, url: row.url,
attributes: { attributes: {
@@ -100,6 +101,7 @@ const buildInitialRows = (source: string): PostImportRow[] =>
tagSources: { tagSources: {
automatic: '', automatic: '',
manual: '' }, manual: '' },
displayTags: [],
fieldWarnings: { }, fieldWarnings: { },
baseWarnings: [] } })) baseWarnings: [] } }))