@things-factory/gallery 0.0.0-stage → 10.1.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/bootstrap.ts +16 -0
- package/client/components/gallery-forms.ts +188 -0
- package/client/components/gallery-uploader.ts +557 -0
- package/client/components/gallery-viewer.ts +397 -0
- package/client/index.ts +1 -0
- package/client/pages/gallery-page.ts +567 -0
- package/client/route.ts +8 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/gallery-api.ts +157 -0
- package/client/utils/gallery-badge.ts +44 -0
- package/client/utils/gallery-words.ts +22 -0
- package/client/utils/photo-prep.ts +93 -0
- package/dist-client/bootstrap.d.ts +1 -0
- package/dist-client/bootstrap.js +16 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/gallery-forms.d.ts +31 -0
- package/dist-client/components/gallery-forms.js +223 -0
- package/dist-client/components/gallery-forms.js.map +1 -0
- package/dist-client/components/gallery-uploader.d.ts +51 -0
- package/dist-client/components/gallery-uploader.js +588 -0
- package/dist-client/components/gallery-uploader.js.map +1 -0
- package/dist-client/components/gallery-viewer.d.ts +30 -0
- package/dist-client/components/gallery-viewer.js +416 -0
- package/dist-client/components/gallery-viewer.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/pages/gallery-page.d.ts +55 -0
- package/dist-client/pages/gallery-page.js +586 -0
- package/dist-client/pages/gallery-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +9 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/gallery-api.d.ts +95 -0
- package/dist-client/utils/gallery-api.js +46 -0
- package/dist-client/utils/gallery-api.js.map +1 -0
- package/dist-client/utils/gallery-badge.d.ts +4 -0
- package/dist-client/utils/gallery-badge.js +45 -0
- package/dist-client/utils/gallery-badge.js.map +1 -0
- package/dist-client/utils/gallery-words.d.ts +7 -0
- package/dist-client/utils/gallery-words.js +20 -0
- package/dist-client/utils/gallery-words.js.map +1 -0
- package/dist-client/utils/photo-prep.d.ts +10 -0
- package/dist-client/utils/photo-prep.js +102 -0
- package/dist-client/utils/photo-prep.js.map +1 -0
- package/dist-server/attachment-reader.d.ts +1 -0
- package/dist-server/attachment-reader.js +13 -0
- package/dist-server/attachment-reader.js.map +1 -0
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +15 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/service/gallery/gallery-resolver.d.ts +29 -0
- package/dist-server/service/gallery/gallery-resolver.js +602 -0
- package/dist-server/service/gallery/gallery-resolver.js.map +1 -0
- package/dist-server/service/gallery/gallery-rules.d.ts +88 -0
- package/dist-server/service/gallery/gallery-rules.js +181 -0
- package/dist-server/service/gallery/gallery-rules.js.map +1 -0
- package/dist-server/service/gallery/gallery-service.d.ts +37 -0
- package/dist-server/service/gallery/gallery-service.js +234 -0
- package/dist-server/service/gallery/gallery-service.js.map +1 -0
- package/dist-server/service/gallery/gallery-types.d.ts +62 -0
- package/dist-server/service/gallery/gallery-types.js +227 -0
- package/dist-server/service/gallery/gallery-types.js.map +1 -0
- package/dist-server/service/gallery/gallery.d.ts +83 -0
- package/dist-server/service/gallery/gallery.js +330 -0
- package/dist-server/service/gallery/gallery.js.map +1 -0
- package/dist-server/service/gallery/index.d.ts +4 -0
- package/dist-server/service/gallery/index.js +8 -0
- package/dist-server/service/gallery/index.js.map +1 -0
- package/dist-server/service/index.d.ts +7 -0
- package/dist-server/service/index.js +23 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -4
- package/server/attachment-reader.ts +14 -0
- package/server/index.ts +9 -0
- package/server/role-tasks.ts +14 -0
- package/server/service/gallery/gallery-resolver.ts +541 -0
- package/server/service/gallery/gallery-rules.ts +187 -0
- package/server/service/gallery/gallery-service.ts +254 -0
- package/server/service/gallery/gallery-types.ts +151 -0
- package/server/service/gallery/gallery.ts +266 -0
- package/server/service/gallery/index.ts +5 -0
- package/server/service/index.ts +19 -0
- package/server/tsconfig.json +11 -0
- package/test/gallery-persistence.test.ts +372 -0
- package/test/gallery-rules.test.ts +140 -0
- package/test/open-doors.test.ts +62 -0
- package/test/photo-prep.test.ts +61 -0
- package/test/translations.test.ts +68 -0
- package/things-factory.config.js +8 -0
- package/translations/en.json +91 -0
- package/translations/es.json +91 -0
- package/translations/ja.json +91 -0
- package/translations/ko.json +91 -0
- package/translations/ms.json +91 -0
- package/translations/zh.json +91 -0
- package/README.md +0 -3
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The gallery's rules that need no database (ADR-0165): tags, what a post may hold, who sees a post, the events offered
|
|
3
|
+
* for a photo and who hears of an event's first photo.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
firstPhotoReceivers,
|
|
7
|
+
galleryAddressOf,
|
|
8
|
+
hasTag,
|
|
9
|
+
inputRefusal,
|
|
10
|
+
isPhoto,
|
|
11
|
+
nearEvent,
|
|
12
|
+
orderChoices,
|
|
13
|
+
photoCountRefusal,
|
|
14
|
+
renamedTags,
|
|
15
|
+
seesPost,
|
|
16
|
+
tagCounts,
|
|
17
|
+
tagOf,
|
|
18
|
+
tagsOf
|
|
19
|
+
} from '../server/service/gallery/gallery-rules'
|
|
20
|
+
|
|
21
|
+
describe('tags', () => {
|
|
22
|
+
it('drops the # mark and the spaces around, keeps one space inside', () => {
|
|
23
|
+
expect(tagOf(' ##가을 워크숍 ')).toBe('가을 워크숍')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('keeps each tag once whatever its case, in the order given, and no empty one', () => {
|
|
27
|
+
expect(tagsOf(['#Workshop', 'workshop', '', ' ', '팀', '#팀'])).toEqual(['Workshop', '팀'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renames a tag and merges it into one already there', () => {
|
|
31
|
+
expect(renamedTags(['워크숍2026', '2026워크숍', '팀'], '#워크숍2026', '2026워크숍')).toEqual(['2026워크숍', '팀'])
|
|
32
|
+
expect(renamedTags(['a', 'b'], 'c', 'd')).toEqual(['a', 'b'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('finds a tag whatever its case or mark', () => {
|
|
36
|
+
expect(hasTag(['Workshop'], '#workshop')).toBe(true)
|
|
37
|
+
expect(hasTag(['Workshop2026'], 'workshop')).toBe(false)
|
|
38
|
+
expect(hasTag(['a'], '')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('counts the tags of the domain, the most used first, narrowed by text', () => {
|
|
42
|
+
const lists = [['워크숍', '팀'], ['워크숍'], ['봄 소풍'], null]
|
|
43
|
+
expect(tagCounts(lists)).toEqual([
|
|
44
|
+
{ tag: '워크숍', count: 2 },
|
|
45
|
+
{ tag: '봄 소풍', count: 1 },
|
|
46
|
+
{ tag: '팀', count: 1 }
|
|
47
|
+
])
|
|
48
|
+
expect(tagCounts(lists, '#워크')).toEqual([{ tag: '워크숍', count: 2 }])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('what a post may hold', () => {
|
|
53
|
+
it('takes a plain post', () => {
|
|
54
|
+
expect(inputRefusal({ space: 'COMPANY', caption: '가을 워크숍', tags: ['워크숍'] })).toBeUndefined()
|
|
55
|
+
expect(inputRefusal({})).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('refuses an unknown space, a long caption, too many or too long tags', () => {
|
|
59
|
+
expect(inputRefusal({ space: 'SECRET' })).toBe('unknown-space')
|
|
60
|
+
expect(inputRefusal({ caption: 'x'.repeat(501) })).toBe('caption-too-long')
|
|
61
|
+
expect(inputRefusal({ tags: Array.from({ length: 11 }, (_, i) => `t${i}`) })).toBe('too-many-tags')
|
|
62
|
+
expect(inputRefusal({ tags: ['x'.repeat(31)] })).toBe('tag-too-long')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("refuses an event in a person's own gallery - events are the company's", () => {
|
|
66
|
+
expect(inputRefusal({ space: 'PERSONAL', eventId: 'e' })).toBe('event-in-personal')
|
|
67
|
+
expect(inputRefusal({ space: 'COMPANY', eventId: 'e' })).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('holds 1 to 20 photos, and only photos', () => {
|
|
71
|
+
expect(photoCountRefusal(0)).toBe('no-photo')
|
|
72
|
+
expect(photoCountRefusal(1)).toBeUndefined()
|
|
73
|
+
expect(photoCountRefusal(20)).toBeUndefined()
|
|
74
|
+
expect(photoCountRefusal(21)).toBe('too-many-photos')
|
|
75
|
+
expect(isPhoto('image/jpeg')).toBe(true)
|
|
76
|
+
expect(isPhoto('application/pdf')).toBe(false)
|
|
77
|
+
expect(isPhoto(undefined)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('who sees a post', () => {
|
|
82
|
+
const viewer = (userId: string, moderator = false) => ({ userId, moderator, activePeople: new Set(['me', 'kim']) })
|
|
83
|
+
|
|
84
|
+
it('everyone of the domain sees a company post and the own gallery of someone here', () => {
|
|
85
|
+
expect(seesPost({ space: 'COMPANY', creatorId: 'kim' }, viewer('me'))).toBe(true)
|
|
86
|
+
expect(seesPost({ space: 'PERSONAL', creatorId: 'kim' }, viewer('me'))).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('a hidden post is seen by its owner and the moderators only', () => {
|
|
90
|
+
const hidden = { space: 'COMPANY', creatorId: 'kim', hiddenAt: new Date() }
|
|
91
|
+
expect(seesPost(hidden, viewer('me'))).toBe(false)
|
|
92
|
+
expect(seesPost(hidden, viewer('kim'))).toBe(true)
|
|
93
|
+
expect(seesPost(hidden, viewer('me', true))).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('the own gallery of someone who left is no longer shown; their company posts stay', () => {
|
|
97
|
+
expect(seesPost({ space: 'PERSONAL', creatorId: 'left' }, viewer('me'))).toBe(false)
|
|
98
|
+
expect(seesPost({ space: 'PERSONAL', creatorId: 'left' }, viewer('me', true))).toBe(false)
|
|
99
|
+
expect(seesPost({ space: 'COMPANY', creatorId: 'left' }, viewer('me'))).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('the events offered for a photo', () => {
|
|
104
|
+
const at = (day: number, hour = 9) => new Date(Date.UTC(2026, 9, day, hour))
|
|
105
|
+
const workshop = { id: 'w', startAt: at(24), endAt: at(25, 18) }
|
|
106
|
+
const meeting = { id: 'm', startAt: at(20), endAt: at(20, 11) }
|
|
107
|
+
const party = { id: 'p', startAt: at(1), endAt: at(1, 22) }
|
|
108
|
+
|
|
109
|
+
it('is near from a day before it starts to a day after it ends', () => {
|
|
110
|
+
expect(nearEvent(workshop, at(23, 10))).toBe(true)
|
|
111
|
+
expect(nearEvent(workshop, at(26, 17))).toBe(true)
|
|
112
|
+
expect(nearEvent(workshop, at(22))).toBe(false)
|
|
113
|
+
expect(nearEvent(workshop, at(27))).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('offers the near ones first, the closest start first, then the newest - each once', () => {
|
|
117
|
+
const order = orderChoices([party, meeting, workshop, workshop], at(25, 12)).map(one => [one.id, one.near])
|
|
118
|
+
expect(order).toEqual([
|
|
119
|
+
['w', true],
|
|
120
|
+
['m', false],
|
|
121
|
+
['p', false]
|
|
122
|
+
])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('who hears of an event’s first photo', () => {
|
|
127
|
+
it('the people named on the event, never the one who posted it', () => {
|
|
128
|
+
expect(firstPhotoReceivers(['a', 'b', 'me', 'a'], ['a', 'b', 'c', 'me'], 'me')).toEqual(['a', 'b'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('everyone of the domain when the event names nobody', () => {
|
|
132
|
+
expect(firstPhotoReceivers([], ['a', 'b', 'me'], 'me')).toEqual(['a', 'b'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('opens the gallery on the event, or the post', () => {
|
|
136
|
+
expect(galleryAddressOf({ eventId: 'e' })).toBe('gallery-page?event=e')
|
|
137
|
+
expect(galleryAddressOf({ postId: 'p', eventId: 'e' })).toBe('gallery-page?post=p')
|
|
138
|
+
expect(galleryAddressOf({})).toBe('gallery-page')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Every door of the gallery is open to the domain on purpose, except moderating, which asks for `gallery:mutation` and
|
|
3
|
+
* admits the domain owner. Everyone of the domain sees every post (ADR-0165 decision 2); each open door asks the post.
|
|
4
|
+
*/
|
|
5
|
+
import { gatesOf, openFields } from '../../../test/helpers/open-doors'
|
|
6
|
+
|
|
7
|
+
const EVERYONE =
|
|
8
|
+
'everyone of the domain looks, posts and likes - the door asks the post, not a privilege (ADR-0165 decision 7)'
|
|
9
|
+
const OWNER = "one's own post - the door refuses anyone but the one who posted it (ADR-0165 decision 5)"
|
|
10
|
+
|
|
11
|
+
/** Open on purpose, each with its reason. */
|
|
12
|
+
const OPEN: Record<'Query' | 'Mutation', Record<string, string>> = {
|
|
13
|
+
Query: {
|
|
14
|
+
galleryPosts: EVERYONE,
|
|
15
|
+
galleryPost: EVERYONE,
|
|
16
|
+
galleryLikers: EVERYONE,
|
|
17
|
+
galleryTags: EVERYONE,
|
|
18
|
+
galleryEvents: EVERYONE,
|
|
19
|
+
galleryEventChoices:
|
|
20
|
+
'the events the person may post to - their own, the open ones, all for a calendar reader (ADR-0165 사용자 판정 1)',
|
|
21
|
+
gallerySummary: EVERYONE
|
|
22
|
+
},
|
|
23
|
+
Mutation: {
|
|
24
|
+
createGalleryPost: EVERYONE,
|
|
25
|
+
likeGalleryPost: EVERYONE,
|
|
26
|
+
visitGallery: "the person's own last look",
|
|
27
|
+
updateGalleryPost: OWNER,
|
|
28
|
+
deleteGalleryPost: OWNER
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let gates: Awaited<ReturnType<typeof gatesOf>>
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
gates = await gatesOf('gallery')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it.each(['Query', 'Mutation'] as const)('★ every open %s door has a written reason', root => {
|
|
39
|
+
expect(openFields(gates[root])).toEqual(Object.keys(OPEN[root]).sort())
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it.each(['Query', 'Mutation'] as const)(
|
|
43
|
+
'★ every other %s door asks for gallery:mutation and admits the domain owner',
|
|
44
|
+
root => {
|
|
45
|
+
for (const [field, gate] of Object.entries(gates[root])) {
|
|
46
|
+
if (field in OPEN[root]) continue
|
|
47
|
+
expect({ field, ...gate }).toEqual({
|
|
48
|
+
field,
|
|
49
|
+
category: 'gallery',
|
|
50
|
+
privilege: 'mutation',
|
|
51
|
+
owner: true,
|
|
52
|
+
super: false
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
it('the walk reaches every door', () => {
|
|
59
|
+
/* If the resolver list shrank, the checks above would pass by having nothing to look at. */
|
|
60
|
+
expect(Object.keys(gates.Query)).toHaveLength(7)
|
|
61
|
+
expect(Object.keys(gates.Mutation)).toHaveLength(9)
|
|
62
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* When a photo was taken, read in the browser from the camera's record (ADR-0165 decision 3) - it drives the events
|
|
3
|
+
* offered first. A JPEG is built here byte by byte: SOI, an APP1 "Exif" segment holding a TIFF header, IFD0 and the
|
|
4
|
+
* Exif IFD.
|
|
5
|
+
*/
|
|
6
|
+
import { exifDateOf, exifTakenAt } from '../client/utils/photo-prep'
|
|
7
|
+
|
|
8
|
+
/** A JPEG whose IFD0 holds DateTime and, when given, a pointer to an Exif IFD holding DateTimeOriginal. */
|
|
9
|
+
function jpeg({ saved, taken, little = true }: { saved?: string; taken?: string; little?: boolean }): ArrayBuffer {
|
|
10
|
+
const tiff: number[] = []
|
|
11
|
+
const u16 = (n: number) => (little ? [n & 0xff, n >> 8] : [n >> 8, n & 0xff])
|
|
12
|
+
const u32 = (n: number) =>
|
|
13
|
+
little
|
|
14
|
+
? [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, n >>> 24]
|
|
15
|
+
: [n >>> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
|
|
16
|
+
const ascii = (s: string) => [...s].map(c => c.charCodeAt(0)).concat(0)
|
|
17
|
+
const ifd0Entries = (saved ? 1 : 0) + (taken ? 1 : 0)
|
|
18
|
+
const ifd0 = 8
|
|
19
|
+
const ifd0Size = 2 + ifd0Entries * 12 + 4
|
|
20
|
+
const exifIfd = ifd0 + ifd0Size
|
|
21
|
+
const exifSize = taken ? 2 + 12 + 4 : 0
|
|
22
|
+
const savedAt = exifIfd + exifSize
|
|
23
|
+
const takenAt = savedAt + (saved ? 20 : 0)
|
|
24
|
+
tiff.push(...(little ? [0x49, 0x49] : [0x4d, 0x4d]), ...u16(42), ...u32(ifd0))
|
|
25
|
+
tiff.push(...u16(ifd0Entries))
|
|
26
|
+
if (saved) tiff.push(...u16(0x0132), ...u16(2), ...u32(20), ...u32(savedAt))
|
|
27
|
+
if (taken) tiff.push(...u16(0x8769), ...u16(4), ...u32(1), ...u32(exifIfd))
|
|
28
|
+
tiff.push(...u32(0))
|
|
29
|
+
if (taken) tiff.push(...u16(1), ...u16(0x9003), ...u16(2), ...u32(20), ...u32(takenAt), ...u32(0))
|
|
30
|
+
if (saved) tiff.push(...ascii(saved))
|
|
31
|
+
if (taken) tiff.push(...ascii(taken))
|
|
32
|
+
const app1 = [0x45, 0x78, 0x69, 0x66, 0, 0, ...tiff]
|
|
33
|
+
const size = app1.length + 2
|
|
34
|
+
return new Uint8Array([0xff, 0xd8, 0xff, 0xe1, size >> 8, size & 0xff, ...app1, 0xff, 0xd9]).buffer
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('★ reads when the photo was taken - DateTimeOriginal over DateTime', () => {
|
|
38
|
+
expect(exifTakenAt(jpeg({ saved: '2026:10:30 08:00:00', taken: '2026:10:24 14:05:09' }))).toEqual(
|
|
39
|
+
new Date(2026, 9, 24, 14, 5, 9)
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('reads a big-endian record too', () => {
|
|
44
|
+
expect(exifTakenAt(jpeg({ taken: '2026:10:24 14:05:09', little: false }))).toEqual(new Date(2026, 9, 24, 14, 5, 9))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('falls back to when the camera saved it', () => {
|
|
48
|
+
expect(exifTakenAt(jpeg({ saved: '2025:12:20 19:30:00' }))).toEqual(new Date(2025, 11, 20, 19, 30, 0))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('says nothing for a photo with no record, not a JPEG, or a cut one', () => {
|
|
52
|
+
expect(exifTakenAt(new Uint8Array([0xff, 0xd8, 0xff, 0xd9]).buffer)).toBeUndefined()
|
|
53
|
+
expect(exifTakenAt(new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer)).toBeUndefined()
|
|
54
|
+
expect(exifTakenAt(jpeg({ taken: '2026:10:24 14:05:09' }).slice(0, 30))).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('takes only a real date', () => {
|
|
58
|
+
expect(exifDateOf('0000:00:00 00:00:00')).toBeUndefined()
|
|
59
|
+
expect(exifDateOf('not a date')).toBeUndefined()
|
|
60
|
+
expect(exifDateOf('2026:02:03 04:05:06')).toEqual(new Date(2026, 1, 3, 4, 5, 6))
|
|
61
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Every word the gallery says is in every language the module ships, and no language keeps a word nothing says - the
|
|
3
|
+
* keys are strings, so neither tsc nor eslint sees one missing. Covers the screens' words, the codes drawn into words
|
|
4
|
+
* (spaces, tabs), the refusals the server says (`gallery.refusal.<code>`), the notices and the task's words.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
|
|
9
|
+
const ROOT = join(__dirname, '..')
|
|
10
|
+
const LANGUAGES = ['ko', 'en', 'ja', 'zh', 'es', 'ms']
|
|
11
|
+
|
|
12
|
+
const sources = (dir: string): string[] =>
|
|
13
|
+
readdirSync(dir, { withFileTypes: true }).flatMap(entry =>
|
|
14
|
+
entry.isDirectory()
|
|
15
|
+
? sources(join(dir, entry.name))
|
|
16
|
+
: /\.ts$/.test(entry.name) && !/\.test\.ts$/.test(entry.name)
|
|
17
|
+
? [join(dir, entry.name)]
|
|
18
|
+
: []
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const text = [...sources(join(ROOT, 'client')), ...sources(join(ROOT, 'server'))]
|
|
22
|
+
.map(file => readFileSync(file, 'utf8'))
|
|
23
|
+
.join('\n')
|
|
24
|
+
const locale = (lang: string): Record<string, string> =>
|
|
25
|
+
JSON.parse(readFileSync(join(ROOT, 'translations', `${lang}.json`), 'utf8'))
|
|
26
|
+
|
|
27
|
+
/* A task id is a name, not a word. */
|
|
28
|
+
const used = new Set<string>(
|
|
29
|
+
[...text.matchAll(/'(gallery\.[a-z0-9.-]+[a-z0-9])'/g)]
|
|
30
|
+
.map(match => match[1])
|
|
31
|
+
.filter(key => key !== 'gallery.moderation')
|
|
32
|
+
)
|
|
33
|
+
for (const space of ['COMPANY', 'PERSONAL']) used.add(`gallery.space.${space}`)
|
|
34
|
+
for (const tab of [...text.matchAll(/const TABS: Tab\[\] = \[([^\]]*)\]/g)][0][1].matchAll(/'([a-z]+)'/g))
|
|
35
|
+
used.add(`gallery.tab.${tab[1]}`)
|
|
36
|
+
/* The refusals: those the doors name and those the rules return. */
|
|
37
|
+
for (const [, code] of text.matchAll(/refuse\(context, '([a-z-]+)'/g)) used.add(`gallery.refusal.${code}`)
|
|
38
|
+
const rules = readFileSync(join(ROOT, 'server/service/gallery/gallery-rules.ts'), 'utf8')
|
|
39
|
+
for (const [, code] of rules
|
|
40
|
+
.slice(rules.indexOf('export function inputRefusal'), rules.indexOf('export function isPhoto'))
|
|
41
|
+
.matchAll(/return '([a-z-]+)'/g))
|
|
42
|
+
used.add(`gallery.refusal.${code}`)
|
|
43
|
+
used.add('task.gallery.moderation.name')
|
|
44
|
+
used.add('task.gallery.moderation.description')
|
|
45
|
+
|
|
46
|
+
test('the floor - the parser found the words', () => {
|
|
47
|
+
expect(used.size).toBeGreaterThan(80)
|
|
48
|
+
expect(used).toContain('gallery.refusal.event-in-personal')
|
|
49
|
+
expect(used).toContain('gallery.tab.mine')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it.each(LANGUAGES)('%s has every word, and nothing else', lang => {
|
|
53
|
+
const words = locale(lang)
|
|
54
|
+
expect([...used].filter(key => !words[key]).sort()).toEqual([])
|
|
55
|
+
expect(
|
|
56
|
+
Object.keys(words)
|
|
57
|
+
.filter(key => !used.has(key))
|
|
58
|
+
.sort()
|
|
59
|
+
).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each(LANGUAGES)('%s keeps the {placeholders} of the English sentence', lang => {
|
|
63
|
+
const en = locale('en')
|
|
64
|
+
const words = locale(lang)
|
|
65
|
+
const marks = (sentence: string) => [...sentence.matchAll(/\{(\w+)\}/g)].map(match => match[1]).sort()
|
|
66
|
+
for (const key of Object.keys(en))
|
|
67
|
+
expect({ key, marks: marks(words[key] ?? '') }).toEqual({ key, marks: marks(en[key]) })
|
|
68
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"gallery.title.page": "Gallery",
|
|
3
|
+
"gallery.title.my-gallery": "My gallery",
|
|
4
|
+
"gallery.title.post": "Post photos",
|
|
5
|
+
"gallery.title.edit": "Edit post",
|
|
6
|
+
"gallery.tab.company": "Company",
|
|
7
|
+
"gallery.tab.events": "Events",
|
|
8
|
+
"gallery.tab.all": "All",
|
|
9
|
+
"gallery.tab.mine": "My gallery",
|
|
10
|
+
"gallery.space.COMPANY": "Company gallery",
|
|
11
|
+
"gallery.space.PERSONAL": "My gallery",
|
|
12
|
+
"gallery.field.tags": "Tags",
|
|
13
|
+
"gallery.field.space": "Post to",
|
|
14
|
+
"gallery.field.taken-on": "Taken on",
|
|
15
|
+
"gallery.field.event": "Event",
|
|
16
|
+
"gallery.field.event-name": "Event name",
|
|
17
|
+
"gallery.field.starts": "Starts",
|
|
18
|
+
"gallery.field.ends": "Ends",
|
|
19
|
+
"gallery.field.location": "Place",
|
|
20
|
+
"gallery.field.new-tag": "New tag name",
|
|
21
|
+
"gallery.button.post": "Post photos",
|
|
22
|
+
"gallery.button.post-count": "Post {count}",
|
|
23
|
+
"gallery.button.shoot": "Take a photo",
|
|
24
|
+
"gallery.button.shoot-more": "Take another",
|
|
25
|
+
"gallery.button.add-photos": "Add photos",
|
|
26
|
+
"gallery.button.remove-photo": "Remove photo",
|
|
27
|
+
"gallery.button.remove-tag": "Remove tag",
|
|
28
|
+
"gallery.button.more-events": "{count} more events",
|
|
29
|
+
"gallery.button.cancel": "Cancel",
|
|
30
|
+
"gallery.button.save": "Save",
|
|
31
|
+
"gallery.button.close": "Close",
|
|
32
|
+
"gallery.button.edit": "Edit",
|
|
33
|
+
"gallery.button.delete": "Delete",
|
|
34
|
+
"gallery.button.hide": "Hide",
|
|
35
|
+
"gallery.button.unhide": "Show again",
|
|
36
|
+
"gallery.button.like": "Like",
|
|
37
|
+
"gallery.button.previous": "Previous photo",
|
|
38
|
+
"gallery.button.next": "Next photo",
|
|
39
|
+
"gallery.button.more": "Show more",
|
|
40
|
+
"gallery.button.clear": "Clear tag",
|
|
41
|
+
"gallery.button.create-event": "Create event",
|
|
42
|
+
"gallery.button.rename-tag": "Rename tag",
|
|
43
|
+
"gallery.text.caption-placeholder": "Add a line or two (optional)",
|
|
44
|
+
"gallery.text.tag-placeholder": "Type like #workshop and press Enter",
|
|
45
|
+
"gallery.text.no-event": "No event",
|
|
46
|
+
"gallery.text.personal-note": "Everyone in the workplace sees your own gallery too.",
|
|
47
|
+
"gallery.text.photos-full": "A post holds up to {count} photos.",
|
|
48
|
+
"gallery.text.photos-skipped": "{count} left out - not photos, or no room.",
|
|
49
|
+
"gallery.text.posted": "Posted.",
|
|
50
|
+
"gallery.text.saved": "Saved.",
|
|
51
|
+
"gallery.text.deleted": "Deleted.",
|
|
52
|
+
"gallery.text.delete-confirm": "This deletes the post and its photos. It cannot be undone.",
|
|
53
|
+
"gallery.text.hidden": "Hidden",
|
|
54
|
+
"gallery.text.hidden-note": "A moderator hid this post. Only its owner and the moderators see it",
|
|
55
|
+
"gallery.text.hidden-done": "Hidden. Its owner has been told.",
|
|
56
|
+
"gallery.text.hidden-own": "Hidden.",
|
|
57
|
+
"gallery.text.hide-reason": "Why it is hidden (its owner sees this)",
|
|
58
|
+
"gallery.text.like-count": "{count} likes",
|
|
59
|
+
"gallery.text.persons-gallery": "{name}'s gallery",
|
|
60
|
+
"gallery.text.empty": "No photos yet. Post the first one.",
|
|
61
|
+
"gallery.text.empty-mine": "Your gallery is empty. Post a photo of your day.",
|
|
62
|
+
"gallery.text.empty-event": "No photos of this event yet. Post the first one.",
|
|
63
|
+
"gallery.text.empty-events": "No event has photos yet.",
|
|
64
|
+
"gallery.text.event-form-note": "Creates a company event on the calendar and opens it for everyone in the workplace to post photos to.",
|
|
65
|
+
"gallery.text.tag-form-note": "Renames #{tag} on every post. Into a tag already there, the two become one.",
|
|
66
|
+
"gallery.text.tag-renamed": "Changed the tag on {count} posts.",
|
|
67
|
+
"gallery.text.unknown-post": "There is no such post.",
|
|
68
|
+
"gallery.text.failed": "It could not be done.",
|
|
69
|
+
"gallery.notice.first-photo-subject": "Photos of {event} are up",
|
|
70
|
+
"gallery.notice.first-photo-body": "{poster} posted the first photos of {event}.",
|
|
71
|
+
"gallery.notice.hidden-subject": "Your gallery post was hidden",
|
|
72
|
+
"gallery.notice.hidden-body": "Reason: {reason}",
|
|
73
|
+
"gallery.notice.action": "Open the gallery",
|
|
74
|
+
"gallery.notice.note": "Open Gallery in the application to see them.",
|
|
75
|
+
"gallery.refusal.unknown-post": "There is no such post.",
|
|
76
|
+
"gallery.refusal.not-yours": "Only the one who posted it changes or deletes it.",
|
|
77
|
+
"gallery.refusal.not-a-photo": "Only photos go in the gallery.",
|
|
78
|
+
"gallery.refusal.no-photo": "A post needs at least one photo.",
|
|
79
|
+
"gallery.refusal.too-many-photos": "A post holds up to 20 photos.",
|
|
80
|
+
"gallery.refusal.caption-too-long": "The caption is at most 500 characters.",
|
|
81
|
+
"gallery.refusal.too-many-tags": "Up to 10 tags.",
|
|
82
|
+
"gallery.refusal.tag-too-long": "A tag is at most 30 characters.",
|
|
83
|
+
"gallery.refusal.tag-required": "Give the tag and its new name.",
|
|
84
|
+
"gallery.refusal.unknown-space": "Unknown place to post to.",
|
|
85
|
+
"gallery.refusal.event-in-personal": "Event photos go in the company gallery.",
|
|
86
|
+
"gallery.refusal.unknown-event": "There is no such event to post to.",
|
|
87
|
+
"gallery.refusal.reason-required": "Give the reason it is hidden.",
|
|
88
|
+
"gallery.refusal.reason-too-long": "The reason is at most 200 characters.",
|
|
89
|
+
"task.gallery.moderation.name": "Gallery moderation",
|
|
90
|
+
"task.gallery.moderation.description": "Hides posts or shows them again, tidies the tags, and makes events from the gallery."
|
|
91
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"gallery.title.page": "Galería",
|
|
3
|
+
"gallery.title.my-gallery": "Mi galería",
|
|
4
|
+
"gallery.title.post": "Publicar fotos",
|
|
5
|
+
"gallery.title.edit": "Editar publicación",
|
|
6
|
+
"gallery.tab.company": "Empresa",
|
|
7
|
+
"gallery.tab.events": "Eventos",
|
|
8
|
+
"gallery.tab.all": "Todo",
|
|
9
|
+
"gallery.tab.mine": "Mi galería",
|
|
10
|
+
"gallery.space.COMPANY": "Galería de la empresa",
|
|
11
|
+
"gallery.space.PERSONAL": "Mi galería",
|
|
12
|
+
"gallery.field.tags": "Etiquetas",
|
|
13
|
+
"gallery.field.space": "Publicar en",
|
|
14
|
+
"gallery.field.taken-on": "Tomada el",
|
|
15
|
+
"gallery.field.event": "Evento",
|
|
16
|
+
"gallery.field.event-name": "Nombre del evento",
|
|
17
|
+
"gallery.field.starts": "Empieza",
|
|
18
|
+
"gallery.field.ends": "Termina",
|
|
19
|
+
"gallery.field.location": "Lugar",
|
|
20
|
+
"gallery.field.new-tag": "Nuevo nombre de la etiqueta",
|
|
21
|
+
"gallery.button.post": "Publicar fotos",
|
|
22
|
+
"gallery.button.post-count": "Publicar {count}",
|
|
23
|
+
"gallery.button.shoot": "Hacer foto",
|
|
24
|
+
"gallery.button.shoot-more": "Hacer otra",
|
|
25
|
+
"gallery.button.add-photos": "Añadir fotos",
|
|
26
|
+
"gallery.button.remove-photo": "Quitar foto",
|
|
27
|
+
"gallery.button.remove-tag": "Quitar etiqueta",
|
|
28
|
+
"gallery.button.more-events": "{count} eventos más",
|
|
29
|
+
"gallery.button.cancel": "Cancelar",
|
|
30
|
+
"gallery.button.save": "Guardar",
|
|
31
|
+
"gallery.button.close": "Cerrar",
|
|
32
|
+
"gallery.button.edit": "Editar",
|
|
33
|
+
"gallery.button.delete": "Eliminar",
|
|
34
|
+
"gallery.button.hide": "Ocultar",
|
|
35
|
+
"gallery.button.unhide": "Mostrar de nuevo",
|
|
36
|
+
"gallery.button.like": "Me gusta",
|
|
37
|
+
"gallery.button.previous": "Foto anterior",
|
|
38
|
+
"gallery.button.next": "Foto siguiente",
|
|
39
|
+
"gallery.button.more": "Ver más",
|
|
40
|
+
"gallery.button.clear": "Quitar filtro",
|
|
41
|
+
"gallery.button.create-event": "Crear evento",
|
|
42
|
+
"gallery.button.rename-tag": "Renombrar etiqueta",
|
|
43
|
+
"gallery.text.caption-placeholder": "Añade una o dos líneas (opcional)",
|
|
44
|
+
"gallery.text.tag-placeholder": "Escribe como #taller y pulsa Enter",
|
|
45
|
+
"gallery.text.no-event": "Sin evento",
|
|
46
|
+
"gallery.text.personal-note": "Todos en el lugar de trabajo ven también tu galería.",
|
|
47
|
+
"gallery.text.photos-full": "Una publicación admite hasta {count} fotos.",
|
|
48
|
+
"gallery.text.photos-skipped": "Se omitieron {count}: no son fotos o no caben.",
|
|
49
|
+
"gallery.text.posted": "Publicado.",
|
|
50
|
+
"gallery.text.saved": "Guardado.",
|
|
51
|
+
"gallery.text.deleted": "Eliminado.",
|
|
52
|
+
"gallery.text.delete-confirm": "Se eliminarán la publicación y sus fotos. No se puede deshacer.",
|
|
53
|
+
"gallery.text.hidden": "Oculta",
|
|
54
|
+
"gallery.text.hidden-note": "Un moderador ocultó esta publicación. Solo la ven su autor y los moderadores",
|
|
55
|
+
"gallery.text.hidden-done": "Oculta. Se avisó a su autor.",
|
|
56
|
+
"gallery.text.hidden-own": "Oculta.",
|
|
57
|
+
"gallery.text.hide-reason": "Motivo (lo verá su autor)",
|
|
58
|
+
"gallery.text.like-count": "{count} me gusta",
|
|
59
|
+
"gallery.text.persons-gallery": "Galería de {name}",
|
|
60
|
+
"gallery.text.empty": "Aún no hay fotos. Publica la primera.",
|
|
61
|
+
"gallery.text.empty-mine": "Tu galería está vacía. Publica una foto de tu día.",
|
|
62
|
+
"gallery.text.empty-event": "Aún no hay fotos de este evento. Publica la primera.",
|
|
63
|
+
"gallery.text.empty-events": "Ningún evento tiene fotos aún.",
|
|
64
|
+
"gallery.text.event-form-note": "Crea un evento de la empresa en el calendario y lo abre para que todos publiquen fotos.",
|
|
65
|
+
"gallery.text.tag-form-note": "Renombra #{tag} en todas las publicaciones. Si ya existe, se unen.",
|
|
66
|
+
"gallery.text.tag-renamed": "Se cambió la etiqueta en {count} publicaciones.",
|
|
67
|
+
"gallery.text.unknown-post": "No existe esa publicación.",
|
|
68
|
+
"gallery.text.failed": "No se pudo hacer.",
|
|
69
|
+
"gallery.notice.first-photo-subject": "Ya hay fotos de {event}",
|
|
70
|
+
"gallery.notice.first-photo-body": "{poster} publicó las primeras fotos de {event}.",
|
|
71
|
+
"gallery.notice.hidden-subject": "Tu publicación de la galería se ocultó",
|
|
72
|
+
"gallery.notice.hidden-body": "Motivo: {reason}",
|
|
73
|
+
"gallery.notice.action": "Abrir la galería",
|
|
74
|
+
"gallery.notice.note": "Abre la Galería en la aplicación para verlas.",
|
|
75
|
+
"gallery.refusal.unknown-post": "No existe esa publicación.",
|
|
76
|
+
"gallery.refusal.not-yours": "Solo quien la publicó puede cambiarla o eliminarla.",
|
|
77
|
+
"gallery.refusal.not-a-photo": "Solo se pueden publicar fotos.",
|
|
78
|
+
"gallery.refusal.no-photo": "Hace falta al menos una foto.",
|
|
79
|
+
"gallery.refusal.too-many-photos": "Una publicación admite hasta 20 fotos.",
|
|
80
|
+
"gallery.refusal.caption-too-long": "El texto admite hasta 500 caracteres.",
|
|
81
|
+
"gallery.refusal.too-many-tags": "Hasta 10 etiquetas.",
|
|
82
|
+
"gallery.refusal.tag-too-long": "Una etiqueta admite hasta 30 caracteres.",
|
|
83
|
+
"gallery.refusal.tag-required": "Indica la etiqueta y su nuevo nombre.",
|
|
84
|
+
"gallery.refusal.unknown-space": "Lugar de publicación desconocido.",
|
|
85
|
+
"gallery.refusal.event-in-personal": "Las fotos de eventos van en la galería de la empresa.",
|
|
86
|
+
"gallery.refusal.unknown-event": "No es un evento al que se pueda publicar.",
|
|
87
|
+
"gallery.refusal.reason-required": "Indica el motivo.",
|
|
88
|
+
"gallery.refusal.reason-too-long": "El motivo admite hasta 200 caracteres.",
|
|
89
|
+
"task.gallery.moderation.name": "Moderación de la galería",
|
|
90
|
+
"task.gallery.moderation.description": "Oculta publicaciones o las vuelve a mostrar, ordena las etiquetas y crea eventos desde la galería."
|
|
91
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"gallery.title.page": "ギャラリー",
|
|
3
|
+
"gallery.title.my-gallery": "マイギャラリー",
|
|
4
|
+
"gallery.title.post": "写真を投稿",
|
|
5
|
+
"gallery.title.edit": "投稿を編集",
|
|
6
|
+
"gallery.tab.company": "会社",
|
|
7
|
+
"gallery.tab.events": "イベント",
|
|
8
|
+
"gallery.tab.all": "すべて",
|
|
9
|
+
"gallery.tab.mine": "マイギャラリー",
|
|
10
|
+
"gallery.space.COMPANY": "会社ギャラリー",
|
|
11
|
+
"gallery.space.PERSONAL": "マイギャラリー",
|
|
12
|
+
"gallery.field.tags": "タグ",
|
|
13
|
+
"gallery.field.space": "投稿先",
|
|
14
|
+
"gallery.field.taken-on": "撮影日",
|
|
15
|
+
"gallery.field.event": "イベント",
|
|
16
|
+
"gallery.field.event-name": "イベント名",
|
|
17
|
+
"gallery.field.starts": "開始",
|
|
18
|
+
"gallery.field.ends": "終了",
|
|
19
|
+
"gallery.field.location": "場所",
|
|
20
|
+
"gallery.field.new-tag": "新しいタグ名",
|
|
21
|
+
"gallery.button.post": "写真を投稿",
|
|
22
|
+
"gallery.button.post-count": "{count}枚を投稿",
|
|
23
|
+
"gallery.button.shoot": "撮影",
|
|
24
|
+
"gallery.button.shoot-more": "もう一枚",
|
|
25
|
+
"gallery.button.add-photos": "写真を追加",
|
|
26
|
+
"gallery.button.remove-photo": "写真を外す",
|
|
27
|
+
"gallery.button.remove-tag": "タグを外す",
|
|
28
|
+
"gallery.button.more-events": "ほか{count}件",
|
|
29
|
+
"gallery.button.cancel": "キャンセル",
|
|
30
|
+
"gallery.button.save": "保存",
|
|
31
|
+
"gallery.button.close": "閉じる",
|
|
32
|
+
"gallery.button.edit": "編集",
|
|
33
|
+
"gallery.button.delete": "削除",
|
|
34
|
+
"gallery.button.hide": "非表示",
|
|
35
|
+
"gallery.button.unhide": "再表示",
|
|
36
|
+
"gallery.button.like": "いいね",
|
|
37
|
+
"gallery.button.previous": "前の写真",
|
|
38
|
+
"gallery.button.next": "次の写真",
|
|
39
|
+
"gallery.button.more": "もっと見る",
|
|
40
|
+
"gallery.button.clear": "タグを解除",
|
|
41
|
+
"gallery.button.create-event": "イベントを作成",
|
|
42
|
+
"gallery.button.rename-tag": "タグ名を変更",
|
|
43
|
+
"gallery.text.caption-placeholder": "ひとことどうぞ(任意)",
|
|
44
|
+
"gallery.text.tag-placeholder": "#ワークショップ のように入力して Enter",
|
|
45
|
+
"gallery.text.no-event": "イベントなし",
|
|
46
|
+
"gallery.text.personal-note": "マイギャラリーの写真も職場の全員が見られます。",
|
|
47
|
+
"gallery.text.photos-full": "1件の投稿に写真は{count}枚までです。",
|
|
48
|
+
"gallery.text.photos-skipped": "写真でないか上限のため{count}件を除きました。",
|
|
49
|
+
"gallery.text.posted": "投稿しました。",
|
|
50
|
+
"gallery.text.saved": "保存しました。",
|
|
51
|
+
"gallery.text.deleted": "削除しました。",
|
|
52
|
+
"gallery.text.delete-confirm": "この投稿と写真を削除します。元に戻せません。",
|
|
53
|
+
"gallery.text.hidden": "非表示",
|
|
54
|
+
"gallery.text.hidden-note": "管理者が非表示にした投稿です。投稿者と管理者だけが見られます",
|
|
55
|
+
"gallery.text.hidden-done": "非表示にしました。投稿者に通知しました。",
|
|
56
|
+
"gallery.text.hidden-own": "非表示にしました。",
|
|
57
|
+
"gallery.text.hide-reason": "非表示の理由(投稿者に表示されます)",
|
|
58
|
+
"gallery.text.like-count": "いいね {count}",
|
|
59
|
+
"gallery.text.persons-gallery": "{name}さんのギャラリー",
|
|
60
|
+
"gallery.text.empty": "まだ写真がありません。最初の一枚を投稿しましょう。",
|
|
61
|
+
"gallery.text.empty-mine": "マイギャラリーは空です。日常の写真を投稿しましょう。",
|
|
62
|
+
"gallery.text.empty-event": "このイベントの写真はまだありません。最初の一枚を投稿しましょう。",
|
|
63
|
+
"gallery.text.empty-events": "写真のあるイベントはまだありません。",
|
|
64
|
+
"gallery.text.event-form-note": "カレンダーに会社のイベントとして作成し、職場の誰もが写真を投稿できるようにします。",
|
|
65
|
+
"gallery.text.tag-form-note": "すべての投稿の #{tag} を変更します。既存のタグ名なら一つにまとまります。",
|
|
66
|
+
"gallery.text.tag-renamed": "{count}件の投稿のタグを変更しました。",
|
|
67
|
+
"gallery.text.unknown-post": "投稿が見つかりません。",
|
|
68
|
+
"gallery.text.failed": "処理できませんでした。",
|
|
69
|
+
"gallery.notice.first-photo-subject": "{event}の写真が投稿されました",
|
|
70
|
+
"gallery.notice.first-photo-body": "{poster}さんが{event}の最初の写真を投稿しました。",
|
|
71
|
+
"gallery.notice.hidden-subject": "ギャラリーの投稿が非表示になりました",
|
|
72
|
+
"gallery.notice.hidden-body": "理由: {reason}",
|
|
73
|
+
"gallery.notice.action": "ギャラリーを開く",
|
|
74
|
+
"gallery.notice.note": "アプリでギャラリーを開いてご覧ください。",
|
|
75
|
+
"gallery.refusal.unknown-post": "投稿が見つかりません。",
|
|
76
|
+
"gallery.refusal.not-yours": "投稿者だけが編集・削除できます。",
|
|
77
|
+
"gallery.refusal.not-a-photo": "写真だけを投稿できます。",
|
|
78
|
+
"gallery.refusal.no-photo": "写真が1枚以上必要です。",
|
|
79
|
+
"gallery.refusal.too-many-photos": "1件の投稿に写真は20枚までです。",
|
|
80
|
+
"gallery.refusal.caption-too-long": "本文は500文字までです。",
|
|
81
|
+
"gallery.refusal.too-many-tags": "タグは10個までです。",
|
|
82
|
+
"gallery.refusal.tag-too-long": "タグは30文字までです。",
|
|
83
|
+
"gallery.refusal.tag-required": "タグと新しい名前を入力してください。",
|
|
84
|
+
"gallery.refusal.unknown-space": "投稿先が不明です。",
|
|
85
|
+
"gallery.refusal.event-in-personal": "イベントの写真は会社ギャラリーに投稿します。",
|
|
86
|
+
"gallery.refusal.unknown-event": "写真を投稿できるイベントではありません。",
|
|
87
|
+
"gallery.refusal.reason-required": "非表示の理由を入力してください。",
|
|
88
|
+
"gallery.refusal.reason-too-long": "理由は200文字までです。",
|
|
89
|
+
"task.gallery.moderation.name": "ギャラリー管理",
|
|
90
|
+
"task.gallery.moderation.description": "投稿の非表示・再表示、タグの整理、ギャラリーからのイベント作成。"
|
|
91
|
+
}
|