diff --git a/CLAUDE.md b/CLAUDE.md index 2c5e119a..db95e75d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,7 +173,11 @@ restores the image but never the schema, so keep migrations additive. send form posts to `/api/v1/club/reports` instead of opening a GitHub issue, and a published report's text and photos become a contributed unit on its board (`boards.ApplyReportUnits`), as `boards/contributions.yml` - does for what arrived as issues (#365, #366). + does for what arrived as issues (#365, #366). A camera the catalogue + lacks is sent from `/cameras/report#new` as its maker, its marking and + photos (`report_proposals`, migration 028), and publishing it from the + review queue adds the board (`boards.CreateModel`, alias source `club`) + -- the only way a model is made besides the importers. - `internal/wallstars` -- **stars for keeping a camera on the Open Wall** (migration 024). A member links a camera by making it upload a one-time code from `/club`: pasted at the end of the WebUI's OpenWall caption on diff --git a/data/locales/boards.en.yml b/data/locales/boards.en.yml index a7037ab6..1a3ec934 100644 --- a/data/locales/boards.en.yml +++ b/data/locales/boards.en.yml @@ -32,6 +32,7 @@ en: fact2: "The MAC, the chip's serial and the cloud ID are replaced with hashes in everything published, in the report and in any log." fact3_title: "A backup is private unless you say otherwise" fact3_html: "It holds everything the camera stores, including Wi-Fi keys and passwords. It is published only if you sent it with --public." + new_title: "No shell on the camera? Send its photos" club: eyebrow: 'OpenIPC Club' title: 'OpenIPC Club' @@ -134,6 +135,8 @@ en: loading: Loading the boards… error: 'The board catalogue could not be loaded ({error}). Reload the page to try again.' empty: No board matches these filters. Clear one to widen the list. + new_missing: Not in the catalogue? + new_add: Add your camera unknown_maker: Unidentified maker also_marked: 'also marked {aliases}' board_count: @@ -453,6 +456,19 @@ en: kind_document: 'Document' kind_yaml: 'ipctool report' no_board: 'No board named' + new_link: 'Add a camera that is not in the catalogue' + new_lede: 'Name the maker and the board, and add photos of it: the board''s front and back, and the label. A maintainer adds it to the catalogue when they publish your report; each photo and file earns {n} ★.' + new_maker: 'Maker' + new_maker_hint: 'Xiongmai, Jooan, Hikvision…' + new_board: 'Board marking or model' + new_board_hint: 'What is printed on the board, or the model on the box' + new_soc: 'SoC, if you know it' + new_photos: 'Photos (at least one)' + new_more: 'Have ipctool''s output or a boot log? Add them too' + new_ipctool: 'ipctool''s output' + new_boot_log: 'Boot log or U-Boot console' + new_need: 'Name the maker and the board, and add at least one photo.' + proposed: 'New: {board}' rules_title: 'How stars work' rules_item: 'Boot log, U-Boot console, photo, pinout, ipctool report' rules_dump: 'Full flash dump the catalogue doesn''t have' @@ -496,6 +512,17 @@ en: review_points: '{points} ★' review_done_publish: 'Published. {points} ★ to the sender.' review_done_reject: 'Rejected.' + review_proposal: 'Proposed by the sender' + review_existing: 'The catalogue already has {board}: the report is published there unless you change the board ids below.' + review_new_board: 'Add this board to the catalogue when publishing' + review_maker_known: 'its maker is in the catalogue' + review_maker_new: 'a new maker' + review_new_maker_name: 'Maker' + review_new_maker_id: 'Maker id' + review_new_model: 'Marking, as printed' + review_new_model_id: 'Board id' + review_new_soc: 'SoC' + review_done_board: 'Published, and {board} added to the catalogue. {points} ★ to the sender.' show_yaml: 'ipctool output' tab_pending: 'Waiting' tab_published: 'Published' diff --git a/data/locales/boards.ru.yml b/data/locales/boards.ru.yml index 8340c7ed..418644b8 100644 --- a/data/locales/boards.ru.yml +++ b/data/locales/boards.ru.yml @@ -32,6 +32,7 @@ ru: fact2: "MAC, серийный номер чипа и облачный ID во всём опубликованном заменены хешами — и в отчёте, и в логах." fact3_title: "Бэкап закрыт, если вы не решите иначе" fact3_html: "В нём всё, что хранит камера, включая ключи Wi-Fi и пароли. Он публикуется, только если вы отправили его с --public." + new_title: "Нет доступа к консоли камеры? Пришлите фото" club: eyebrow: 'Клуб OpenIPC' title: 'Клуб OpenIPC' @@ -140,6 +141,8 @@ ru: loading: Загружаем платы… error: 'Не удалось загрузить каталог плат ({error}). Обновите страницу, чтобы попробовать ещё раз.' empty: Ни одна плата не подходит под эти фильтры. Уберите какой-нибудь из них. + new_missing: Нет в каталоге? + new_add: Добавьте свою камеру unknown_maker: Производитель неизвестен also_marked: 'также маркируется {aliases}' board_count: @@ -494,6 +497,19 @@ ru: kind_document: 'Документ' kind_yaml: 'Отчёт ipctool' no_board: 'Плата не указана' + new_link: 'Добавить камеру, которой нет в каталоге' + new_lede: 'Укажите производителя и плату и приложите фото: плату с обеих сторон и наклейку. Мейнтейнер добавит её в каталог, когда опубликует ваш отчёт; каждое фото и файл приносят {n} ★.' + new_maker: 'Производитель' + new_maker_hint: 'Xiongmai, Jooan, Hikvision…' + new_board: 'Маркировка платы или модель' + new_board_hint: 'Что напечатано на плате или модель на коробке' + new_soc: 'SoC, если знаете' + new_photos: 'Фото (хотя бы одно)' + new_more: 'Есть вывод ipctool или лог загрузки? Добавьте и их' + new_ipctool: 'Вывод ipctool' + new_boot_log: 'Лог загрузки или консоль U-Boot' + new_need: 'Укажите производителя и плату и приложите хотя бы одно фото.' + proposed: 'Новая: {board}' rules_title: 'Как начисляются звёзды' rules_item: 'Лог загрузки, консоль U-Boot, фото, распиновка, отчёт ipctool' rules_dump: 'Полный дамп флеш-памяти, которого нет в каталоге' @@ -537,6 +553,17 @@ ru: review_points: '{points} ★' review_done_publish: 'Опубликовано. Отправителю {points} ★.' review_done_reject: 'Отклонено.' + review_proposal: 'Предложено отправителем' + review_existing: 'В каталоге уже есть {board}: отчёт будет опубликован там, если не поменять идентификаторы плат ниже.' + review_new_board: 'Добавить эту плату в каталог при публикации' + review_maker_known: 'производитель есть в каталоге' + review_maker_new: 'новый производитель' + review_new_maker_name: 'Производитель' + review_new_maker_id: 'Идентификатор производителя' + review_new_model: 'Маркировка, как напечатана' + review_new_model_id: 'Идентификатор платы' + review_new_soc: 'SoC' + review_done_board: 'Опубликовано, {board} добавлена в каталог. Отправителю {points} ★.' show_yaml: 'Вывод ipctool' tab_pending: 'Ждут' tab_published: 'Опубликованы' diff --git a/data/locales/boards.zh.yml b/data/locales/boards.zh.yml index 9ac82b05..4a8e8301 100644 --- a/data/locales/boards.zh.yml +++ b/data/locales/boards.zh.yml @@ -32,6 +32,7 @@ zh: fact2: "在所有公开内容中(报告和日志),MAC、芯片序列号和云 ID 都会被替换为哈希值。" fact3_title: "备份默认不公开" fact3_html: "备份包含摄像头存储的一切,包括 Wi-Fi 密钥和密码。只有使用 --public 发送时才会公开。" + new_title: "无法进入摄像头的命令行?发送照片" club: eyebrow: 'OpenIPC 俱乐部' title: 'OpenIPC 俱乐部' @@ -134,6 +135,8 @@ zh: loading: 正在加载主板… error: '无法加载主板目录({error})。请刷新页面重试。' empty: 没有符合这些筛选条件的主板。去掉一个条件试试。 + new_missing: 目录中没有? + new_add: 添加你的摄像头 unknown_maker: 未知厂商 also_marked: '也标作 {aliases}' board_count: @@ -456,6 +459,19 @@ zh: kind_document: '文档' kind_yaml: 'ipctool 报告' no_board: '未指定电路板' + new_link: '添加目录中没有的摄像头' + new_lede: '填写厂商和电路板,并附上照片:电路板正反面和标签。维护者发布你的报告时会把它加入目录;每张照片和每个文件可获得 {n} ★。' + new_maker: '厂商' + new_maker_hint: 'Xiongmai、Jooan、Hikvision…' + new_board: '电路板丝印或型号' + new_board_hint: '电路板上印的文字,或包装盒上的型号' + new_soc: 'SoC(如果知道)' + new_photos: '照片(至少一张)' + new_more: '有 ipctool 输出或启动日志?也一起添加' + new_ipctool: 'ipctool 输出' + new_boot_log: '启动日志或 U-Boot 控制台' + new_need: '请填写厂商和电路板,并至少添加一张照片。' + proposed: '新:{board}' rules_title: '星星如何计算' rules_item: '启动日志、U-Boot 控制台、照片、引脚图、ipctool 报告' rules_dump: '目录中没有的完整闪存转储' @@ -499,6 +515,17 @@ zh: review_points: '{points} ★' review_done_publish: '已发布。提交者获得 {points} ★。' review_done_reject: '已拒绝。' + review_proposal: '发送者提议' + review_existing: '目录中已有 {board}:除非修改下方的电路板 ID,报告将发布在那里。' + review_new_board: '发布时将此电路板加入目录' + review_maker_known: '厂商已在目录中' + review_maker_new: '新厂商' + review_new_maker_name: '厂商' + review_new_maker_id: '厂商 ID' + review_new_model: '丝印(按原样)' + review_new_model_id: '电路板 ID' + review_new_soc: 'SoC' + review_done_board: '已发布,{board} 已加入目录。发送者获得 {points} ★。' show_yaml: 'ipctool 输出' tab_pending: '待审核' tab_published: '已发布' diff --git a/frontend/apps/site/src/components/boards/Boards.tsx b/frontend/apps/site/src/components/boards/Boards.tsx index d0af2a88..8d8b5300 100644 --- a/frontend/apps/site/src/components/boards/Boards.tsx +++ b/frontend/apps/site/src/components/boards/Boards.tsx @@ -22,7 +22,7 @@ import { lineLabel, lineOptions, sensorOptions, socName, socOptions, stats, subtitle, type Entry, type Group, type Heading, } from '../../lib/boards/model'; import { useBoardsTranslations, type BoardsT } from '../../lib/boards-i18n'; -import type { Locale } from '../../lib/i18n'; +import { pathFor, type Locale } from '../../lib/i18n'; import BoardPanel from './BoardPanel'; import { Chip, SocChip, SourceChips, Tags, Thumb, makerName, type SocLinks } from './parts'; @@ -42,6 +42,7 @@ const depthOf = (state: unknown): number => export default function Boards({ locale, socs }: { locale: Locale; socs: SocLinks }) { const t = useBoardsTranslations(locale); + const addNew = ; const initial = useMemo(() => readQueryString(typeof window === 'undefined' ? '' : window.location.search), []); const [view, setView] = useState({ ...initial, model: null }); const [open, setOpen] = useState(initial.model); @@ -255,12 +256,12 @@ export default function Boards({ locale, socs }: { locale: Locale; socs: SocLink )} 0 && all.every((m) => !has(m, 'boot_log'))} - t={t} names={names} href={href} onOpen={openModel} /> + t={t} names={names} href={href} onOpen={openModel} addNew={addNew} /> : <> {q.length > 0 &&

{t('query_short')}

} {filtered && kept.length > 0 &&

{t('matching', { count: kept.length })}

} - {kept.length === 0 && {t('empty')}} + {kept.length === 0 && {t('empty')} {addNew}} {sections.map(({ maker, count, groups }) => (

@@ -402,9 +403,9 @@ function Card({ m, level, socs, names, sources, t, href, onOpen, catalogue }: Ca const HIT_CLASS: Record = { code: 'font-mono font-bold', name: 'font-semibold', none: 'font-medium' }; -function Hits({ found, kept, q, scope, none, t, names, href, onOpen }: { +function Hits({ found, kept, q, scope, none, t, names, href, onOpen, addNew }: { found: Load | null; kept: Entry[]; q: string; scope: Scope; none: boolean; t: BoardsT; names: Record; - href: (model: string | null) => string; onOpen: (id: string) => void; + href: (model: string | null) => string; onOpen: (id: string) => void; addNew: ComponentChildren; }) { if (!found || found.state === 'loading') return

{t('searching')}

; if (found.state === 'error') { @@ -424,7 +425,7 @@ function Hits({ found, kept, q, scope, none, t, names, href, onOpen }: { {t('hits_lines', { count: hits.length })} {t('hits_boards', { count: boards })} · {t('hits_scope', { scope: t(`scope_${scope}`) })}

{hits.length === 0 && ( - {t('hits_none', { q })}{scope === 'boot_log' && none ? ` ${t('hits_none_boot_log')}` : ''} + {t('hits_none', { q })}{scope === 'boot_log' && none ? ` ${t('hits_none_boot_log')}` : ''} {addNew} )} {hits.map((h) => (
@@ -448,6 +449,11 @@ function Hits({ found, kept, q, scope, none, t, names, href, onOpen }: { ); } +/** A camera the catalogue lacks is added from the report page's form. */ +function AddNew({ href, t }: { href: string; t: BoardsT }) { + return <>{t('new_missing')} {t('new_add')}; +} + function Notice({ children }: { children: ComponentChildren }) { return

{children}

; } diff --git a/frontend/apps/site/src/components/club/Club.tsx b/frontend/apps/site/src/components/club/Club.tsx index 70f05dc2..fda6c675 100644 --- a/frontend/apps/site/src/components/club/Club.tsx +++ b/frontend/apps/site/src/components/club/Club.tsx @@ -322,7 +322,10 @@ function MemberPage({ member, ways, locale, t, onChange }: { {cams ? { void loadCams(); onChange(); }} /> : !error &&
} -

{t('club.mine_title')}

+
+

{t('club.mine_title')}

+ {t('club.new_link')} +
{error && } {!error && reports === null &&
} {reports && reports.length === 0 && ( @@ -349,7 +352,9 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local {reports.map((r) => { const shown = r.status === 'pending' ? r.pending : r.stars; - const board = r.board ? `${r.board.manufacturer} ${r.board.model}` : (r.chip || t('club.no_board')); + const board = r.board ? `${r.board.manufacturer} ${r.board.model}` + : r.proposal ? t('club.proposed', { board: `${r.proposal.maker} ${r.proposal.board}` }) + : (r.chip || t('club.no_board')); return ( diff --git a/frontend/apps/site/src/components/club/NewCamera.test.tsx b/frontend/apps/site/src/components/club/NewCamera.test.tsx new file mode 100644 index 00000000..1f15e981 --- /dev/null +++ b/frontend/apps/site/src/components/club/NewCamera.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +/** + * A camera the catalogue does not have, both ends: the form posts what the + * service reads (reports.proposalOf -- channel web, maker, board, soc, the + * photos, no model), and the review page publishes it with the board the + * service suggested, as the reviewer corrected it. + */ +import { afterEach, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact'; +import NewCameraForm from './NewCameraForm.tsx'; +import Review from './Review.tsx'; +import type { Queued } from '../../lib/club'; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); localStorage.clear(); }); + +function capture() { + const sent: FormData[] = []; + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { + sent.push(init?.body as FormData); + return new Response(JSON.stringify({ id: 'r-abcd2345', receipt_url: '', files: [] }), { status: 201 }); + })); + return sent; +} + +/** The field whose label reads label: exactly, else at its start. */ +function input(c: Element, label: string) { + const labels = [...c.querySelectorAll('label')]; + const own = (l: HTMLLabelElement) => l.childNodes[0]?.textContent?.trim() ?? ''; + const l = labels.find((x) => own(x) === label) ?? labels.find((x) => own(x).startsWith(label)); + return l!.querySelector('input, textarea')!; +} + +test('the form sends the maker, the marking and the photos, and no board id', async () => { + const sent = capture(); + const { container, getByText } = render(); + fireEvent.input(input(container, 'Maker'), { target: { value: ' Jooan ' } }); + fireEvent.input(input(container, 'Board marking'), { target: { value: 'Q9 v2' } }); + fireEvent.input(input(container, 'SoC'), { target: { value: 'SSC335' } }); + fireEvent.input(input(container, 'Boot log'), { target: { value: 'U-Boot 2015.01\n' } }); + const photos = [new File(['a'], 'front.jpg', { type: 'image/jpeg' }), new File(['b'], 'back.jpg', { type: 'image/jpeg' })]; + fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: photos } }); + fireEvent.submit(container.querySelector('form')!); + await waitFor(() => expect(getByText(/Received as r-abcd2345/)).toBeTruthy()); + const f = sent[0]; + expect(f.get('channel')).toBe('web'); + expect(f.get('maker')).toBe('Jooan'); + expect(f.get('board')).toBe('Q9 v2'); + expect(f.get('soc')).toBe('SSC335'); + expect(f.get('model')).toBeNull(); + expect(f.get('yaml')).toBeNull(); + expect(f.getAll('photo').map((p) => (p as File).name)).toEqual(['front.jpg', 'back.jpg']); + expect(await (f.get('boot_log') as File).text()).toBe('U-Boot 2015.01\n'); +}); + +test('the form asks for the maker, the board and a photo before sending', () => { + const sent = capture(); + const { container, getByRole } = render(); + fireEvent.input(input(container, 'Maker'), { target: { value: 'Jooan' } }); + fireEvent.input(input(container, 'Board marking'), { target: { value: 'Q9' } }); + fireEvent.submit(container.querySelector('form')!); + expect(getByRole('alert').textContent).toContain('at least one photo'); + expect(sent).toHaveLength(0); +}); + +const queued = (over: Partial): Queued => ({ + id: 'r-new23456', received_at: '2026-10-06T10:00:00Z', channel: 'web', status: 'pending', chip: '', sensor: '', + board: null, models: [], backup_consent: 'none', member: 'Ivan', file_list: [], potential: 2, + proposal: { maker: 'Jooan', board: 'Q9 v2', soc: 'SSC335' }, + new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9-v2', model: 'Q9 v2', soc: 'SSC335', maker_known: false }, + ...over, +}); + +function review(q: Queued) { + const calls: { url: string; body: unknown }[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (url.startsWith('/api/v1/club/review?')) return new Response(JSON.stringify({ reports: [q] })); + if (url.startsWith('/api/v1/club/review/')) { + calls.push({ url, body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ points: 2, total: 2, board: 'jooan-q9' })); + } + return new Response(JSON.stringify({ member: null })); + })); + return calls; +} + +test('the reviewer publishes a proposal as the board they corrected', async () => { + const calls = review(queued({})); + const { container, findByText, getByText, getByRole } = render(); + expect(await findByText('Jooan Q9 v2')).toBeTruthy(); + expect(getByText('Jooan · Q9 v2 · SSC335')).toBeTruthy(); + expect(getByText(/a new maker/)).toBeTruthy(); + fireEvent.input(input(container, 'Board id'), { target: { value: 'jooan-q9' } }); + fireEvent.click(getByRole('button', { name: /^Publish ·/ })); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].body).toEqual({ + decision: 'publish', models: [], note: '', + new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9', model: 'Q9 v2', soc: 'SSC335' }, + }); + expect(await findByText(/jooan-q9 added to the catalogue/)).toBeTruthy(); +}); + +test('a proposal the catalogue already has is published on that board, not a twin', async () => { + const calls = review(queued({ new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9-v2', model: 'Q9 v2', maker_known: true, existing: 'jooan-q9-v2' } })); + const { findByText, getByRole } = render(); + expect(await findByText(/already has jooan-q9-v2/)).toBeTruthy(); + fireEvent.click(getByRole('button', { name: /^Publish ·/ })); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].body).toEqual({ decision: 'publish', models: ['jooan-q9-v2'], note: '' }); +}); diff --git a/frontend/apps/site/src/components/club/NewCameraForm.tsx b/frontend/apps/site/src/components/club/NewCameraForm.tsx new file mode 100644 index 00000000..7fe1a1f2 --- /dev/null +++ b/frontend/apps/site/src/components/club/NewCameraForm.tsx @@ -0,0 +1,118 @@ +/** + * A camera the catalogue does not have: its maker, its board's marking and + * photos of it are a report (POST /api/v1/club/reports, channel web, maker, + * board and soc instead of a board's model). ipctool's output and a boot log + * help the reviewer and are optional. A maintainer who publishes the report + * adds the board to the catalogue, and the photos become its first unit. + * + * Signing in is not required, as with SendForm; stars are a member's. + */ +import { useEffect, useState } from 'preact/hooks'; +import { useBoardsTranslations } from '../../lib/boards-i18n'; +import { pathFor, type Locale } from '../../lib/i18n'; +import { STARS, fetchMe, remembered, sendReport, type Member, type Sent } from '../../lib/club'; + +const field = 'rounded-md border border-hairline px-2.5 py-1.5 text-sm text-body'; +const area = 'min-h-[72px] w-full resize-y rounded-md border border-hairline bg-surface-alt p-2 font-mono text-[12.5px] leading-snug text-body'; + +export default function NewCameraForm({ locale }: { locale: Locale }) { + const t = useBoardsTranslations(locale); + const [maker, setMaker] = useState(''); + const [board, setBoard] = useState(''); + const [soc, setSoc] = useState(''); + const [photos, setPhotos] = useState([]); + const [ipctool, setIpctool] = useState(''); + const [bootLog, setBootLog] = useState(''); + const [note, setNote] = useState(''); + const [member, setMember] = useState(() => (remembered() ? undefined : null)); + const [state, setState] = useState<{ s: 'idle' | 'sending' } | { s: 'sent'; sent: Sent } | { s: 'error'; error: string }>({ s: 'idle' }); + + useEffect(() => { + if (remembered()) fetchMe().then((me) => setMember(me.member)).catch(() => setMember(null)); + }, []); + + const submit = (e: Event) => { + e.preventDefault(); + if (!maker.trim() || !board.trim() || photos.length === 0) { + setState({ s: 'error', error: t('club.new_need') }); + return; + } + const form = new FormData(); + form.set('channel', 'web'); + form.set('maker', maker.trim()); + form.set('board', board.trim()); + if (soc.trim()) form.set('soc', soc.trim()); + if (note.trim()) form.set('note', note.trim()); + if (ipctool.trim()) form.set('yaml', ipctool); + if (bootLog.trim()) form.append('boot_log', new Blob([bootLog], { type: 'text/plain' }), 'boot_log.txt'); + for (const f of photos) form.append('photo', f, f.name); + setState({ s: 'sending' }); + sendReport(form) + .then((sent) => setState({ s: 'sent', sent })) + .catch((err: Error) => setState({ s: 'error', error: t('club.send_failed', { error: err.message }) })); + }; + + if (state.s === 'sent') { + const id = state.sent.id; + return ( +
+

{t(member ? 'club.sent_member' : 'club.sent_guest', { id })}

+

+ {member + ? {t('club.my_link')} + : {t('club.receipt')}} +

+
+ ); + } + + return ( +
+

{t('club.new_lede', { n: STARS.item })}

+
+ + + +
+ +
+ {t('club.new_more')} +
+